Tic-Tac-Toe

Tic-Tac-Toe demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.

App.tsx

import './App.css';

import { Component, has } from '@expressive/react';

const LINES = [
  [0, 1, 2],
  [3, 4, 5],
  [6, 7, 8],
  [0, 3, 6],
  [1, 4, 7],
  [2, 5, 8],
  [0, 4, 8],
  [2, 4, 6]
];

class Game extends Component {
  board = has<string>(Array(9).fill(''));
  turn: 'X' | 'O' = 'X';

  get winner() {
    const { board } = this;
    for (const line of LINES) {
      const [a, b, c] = line.map((i) => board.get(i));
      if (a && a === b && b === c) return { player: a, line };
    }
  }

  get full() {
    return [...this.board].every(Boolean);
  }

  play(i: number) {
    if (this.board.get(i) || this.winner) return;

    this.board.set(i, this.turn);
    this.turn = this.turn === 'X' ? 'O' : 'X';
  }

  reset() {
    for (let i = 0; i < 9; i++) this.board.set(i, '');
    this.turn = 'X';
  }

  render() {
    const { board, turn, winner, full, play, reset } = this;

    return (
      <div className="container">
        <h1>Tic Tac Toe</h1>
        <p className="status">
          {winner ? `${winner.player} wins!` : full ? 'Draw!' : `${turn}'s turn`}
        </p>
        <div className={`board ${winner && 'done'}`}>
          {board.map((cell, i) => (
            <button
              key={i}
              onClick={() => play(i)}
              className={`${cell} ${winner?.line.includes(i) ? 'wins' : ''}`}>
              {cell}
            </button>
          ))}
        </div>
        <button onClick={reset}>New game</button>
      </div>
    );
  }
}

export default Game;

App.css

.status {
  font-size: var(--t-lg);
  color: var(--muted);
  margin: 0;
}

/* Win highlight is local to this example - the only color it adds. */
.board {
  --win: #16a34a;
  --win-soft: #16a34a18;
  display: grid;
  grid-template-columns: repeat(3, 4rem);
  grid-template-rows: repeat(3, 4rem);
  gap: 6px;
  margin: 0;
}

.board > button {
  margin: 0;
  padding: 0;
  width: 4rem;
  height: 4rem;
  font-size: var(--t-2xl);
  font-weight: 700;
  color: var(--fg-soft);
  border-radius: var(--r-sm);
  cursor: pointer;
  transition: background-color 0.2s;
}

/* Empty cells invite a move; decided cells don't react to hover. */
.board > button:hover {
  background-color: var(--accent-soft);
}

.board > button.X {
  color: var(--accent);
  background: var(--bg);
  cursor: default;
}

.board > button.O {
  color: var(--muted);
  background: var(--bg);
  cursor: default;
}

.board > button.wins {
  background: var(--win-soft);
  border-color: var(--win);
  color: var(--win);
  cursor: default;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) .board {
    --win: #22c55e;
    --win-soft: #22c55e22;
  }
}

:root[data-theme='dark'] .board {
  --win: #22c55e;
  --win-soft: #22c55e22;
}