# 4. Render the live board

The server can now render the browser view. Create `src/board.tsx`:


```tsx
import type { GamePageState } from './players.js';
import type { gameHandlers } from './handlers.js';

type Props = GamePageState & Pick<ReturnType<typeof gameHandlers>, 'Join' | 'Move'> & { account: string };

export function Board({ game, notice, account, Join, Move }: Props) {
    const canMove = game && game.players.length === 2 && !game.result && game.you === game.turn;
    const outcome = game?.result === 'draw' ? 'Draw!' : game?.result ? `${game.result} wins!` : `Turn: ${game?.turn}`;
    return <section><h1>Tic-tac-toe</h1><p>Signed in as {account}</p>
        <form method="post" action="/logout"><button>Sign out all sessions</button></form>
        <p>Share a room name with another player. The first two accounts reserve its seats.</p>
        {!game && <form id="join" rw-submit={Join}>
            <label>Room <input name="room" pattern="[a-z0-9-]{1,32}" maxlength="32" required /></label><button>Join room</button>
        </form>}
        <p id="connection" role="status"><span class="online">Connected</span><span class="offline">Connecting… moves are paused</span></p>
        <p id="status" role="status">{game ? `Room ${game.room} · You: ${game.you} · ${outcome} · Online: ${game.online.join(', ')}` : 'Choose a room.'}</p>
        <div id="board" aria-label="Game board">{(game?.board ?? Array<null>(9).fill(null)).map((mark, cell) =>
            <button key={cell} data-cell={cell} aria-label={`Square ${cell + 1}`} disabled={!canMove || mark !== null}
                rw-click={Move.with({ cell, revision: game?.revision ?? 0 })}>{mark ?? '·'}</button>)}</div>
        <p id="notice" role="alert">{notice}</p><p><a href="/">Choose another room</a></p>
        <link rel="stylesheet" href="/game.css" />
    </section>;
}
```


This is TSX, but it is not React. Redweb renders it on Node. `rw-submit={Join}` sends named form fields to the typed join handler. `rw-click={Move.with({ cell, revision })}` binds a move payload without executing it during rendering.

The disabled squares make the interface understandable; they are never authorization. `Game.move()` still validates the player, turn, revision, result, and cell on the server.

Add the page and `/match` route in `src/routes.tsx`:


```tsx
import { page, state, SocketRoute, type LivePageConnectionContext, type LivePageRequestContext } from 'redweb';
import { match as contract } from './contract.js';
import { Games } from './game.js';
import { gamePlayers, type AccountLookup } from './players.js';
import { gameHandlers } from './handlers.js';
import { matchOptions } from './transport.js';
import { Board } from './board.js';
import type { Snapshot } from './messages.js';

export function matchRoute(account: AccountLookup, origins: (origin: string | undefined) => boolean) {
    const games = new Games();
    const players = gamePlayers(games, account, () => GamePage);
    const { Join, Move, Resume } = gameHandlers(games, players);

    class MatchRoute extends SocketRoute {
        constructor() {
            super({ ...matchOptions(account, origins), handlers: [Join, Move, Resume],
                protocol: contract.protocol, connections: players });
        }
    }

    @page('/', { socket: MatchRoute, authorize: context => account(context.request) === context.principal && Boolean(context.principal) })
    class GamePage {
        @state() game: Snapshot | null = null;
        @state() notice = '';

        connected({ socket }: LivePageConnectionContext) {
            if (!this.game) return;
            const player = players.get(socket);
            const room = this.game.room;
            return player.join(room, () => games.resume(room, player.identity));
        }

        render(context: LivePageRequestContext) {
            return <Board game={this.game} notice={this.notice} account={String(context.principal)} Join={Join} Move={Move} />;
        }
    }
    return { MatchRoute, GamePage };
}
```


`@page('/', { socket: MatchRoute })` gives the rendered page one explicit socket route. The page stores only its private projection and feedback. Shared game rules remain in `Games`; connection and presence mechanics remain in `connectedClients`.

When a retained page reconnects, `connected()` asks the game to confirm its reserved seat before restoring room membership. A fresh page must join normally. Redweb cannot infer durable game ownership from a transport connection.

**Checkpoint:** render waiting, two-player, occupied-square, win, and draw states through a real HTTP listener. Verify the output contains typed `rw-submit`/`rw-click` bindings and no application browser controller.
