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.

Download the complete project Setup, optional configuration, and limits