# 1. Model the game

Let Redweb create the project before writing game code. We are building one tic-tac-toe application from here onward—there is no manual compiler setup and no Hello World or counter detour.

```sh
npx --yes redweb@0.16.1 init redweb-tic-tac-toe --template realtime
cd redweb-tic-tac-toe
npm install
npm install --save-exact redweb-client@0.3.0 express@4.22.2 zod@4.3.6
npm install --save-dev @types/express@4.17.25 c8@10.1.3 playwright@1.58.2
```

The initializer supplies the pinned Redweb dependency, TypeScript, `redweb/tsconfig.json` inheritance, build scripts, asset copying, real tests, `.npmrc`, and `.gitignore`. We chose the smallest TSX starter only as scaffolding; its counter is not a tutorial chapter. Remove its generated page and replace it with the game files introduced below.

The finished reference uses ESM so it can keep the entry point to `await app.run()`. Set `"type": "module"` in `package.json`, change local imports to emitted `.js` paths, and keep the initializer's Redweb JSX/compiler settings. The downloadable `package.json` and `tsconfig.json` show the completed manifest and configuration.

Now create `src/game.ts`. This is the authority for seats, turns, legal moves, wins, draws, and stale revisions:


```ts
export type Mark = 'X' | 'O';
export class GameError extends Error {}
export type Result = Mark | 'draw' | null;
const wins = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]];

/** Synchronous rules: no network, clocks, browser state, or asynchronous turn races. */
export class Game {
    private readonly players: string[] = [];
    private readonly board: (Mark | null)[] = Array(9).fill(null);
    private revision = 0;
    private result: Result = null;

    join(account: string) {
        if (this.players.includes(account)) return;
        if (this.players.length === 2) throw new GameError('This room is full.');
        this.players.push(account);
    }

    snapshot(account: string) {
        const seat = this.players.indexOf(account);
        if (seat < 0) throw new GameError('Join this room first.');
        return { board: [...this.board], players: [...this.players], revision: this.revision,
            result: this.result, turn: (this.revision % 2 === 0 ? 'X' : 'O') as Mark,
            you: (seat === 0 ? 'X' : 'O') as Mark };
    }

    move(account: string, cell: number, revision: number) {
        const { you, turn } = this.snapshot(account);
        if (this.players.length !== 2) throw new GameError('Wait for a second player.');
        if (this.result) throw new GameError('The game is finished. Choose a new room.');
        if (revision !== this.revision) throw new GameError('The board changed. Try again.');
        if (you !== turn) throw new GameError('Wait for your turn.');
        if (!Number.isInteger(cell) || cell < 0 || cell > 8 || this.board[cell] !== null) throw new GameError('Choose an empty square.');
        this.board[cell] = you;
        this.revision++;
        if (wins.some(line => line.every(index => this.board[index] === you))) this.result = you;
        else if (this.revision === 9) this.result = 'draw';
    }
}

/** Rooms are public to signed-in players. Seats stay reserved for reconnects. */
export class Games {
    private readonly rooms = new Map<string, Game>();
    join(room: string, account: string) {
        let game = this.rooms.get(room);
        if (!game) {
            if (this.rooms.size >= 100) throw new GameError('Room capacity reached. Restart this tutorial server.');
            game = new Game();
            this.rooms.set(room, game);
        }
        game.join(account);
        return game;
    }
    resume(room: string, account: string) {
        const game = this.rooms.get(room);
        if (!game) throw new GameError('Room not found. Join a new room.');
        game.snapshot(account);
        return game;
    }
}
```


Nothing here knows about HTTP, WebSockets, HTML, or Redweb. That is deliberate: the rules remain easy to reason about and test. The browser will eventually request a move; only this class can accept it.

The `revision` is the board version the player saw. If two messages race or an old click arrives after reconnect, the stale move is rejected instead of being applied to a newer position.

**Checkpoint:** write unit tests for joining, capacity, alternating turns, occupied squares, all eight winning lines, draws, and stale revisions. Run `npm run build`; the game model is the foundation every later chapter will use.
