# 6. Connect the TSX board and play

The page renders the board and login forms in TSX. Its reusable `Board` component produces the nine buttons. A separate, small browser module displays socket snapshots and forwards clicks; it does not implement game rules.

The final game page is `live: false` because `/match` owns its live behavior. That avoids opening both a live-page action socket and a custom game socket for the same interaction. The earlier counter deliberately demonstrated the automatic page-action alternative.


```ts
import { RedwebClient } from 'redweb-client';
import { match, type Snapshot } from './contract.js';

const client = new RedwebClient('/match', { version: '1', maxQueueSize: 0,
    reconnect: { enabled: true, maxAttempts: 8 },
});
const protocol = match.client({ send: frame => client.sendRaw(frame) });
const form = document.querySelector<HTMLFormElement>('#join')!;
const status = document.querySelector<HTMLElement>('#status')!;
const connection = document.querySelector<HTMLElement>('#connection')!;
const notice = document.querySelector<HTMLElement>('#notice')!;
const cells = [...document.querySelectorAll<HTMLButtonElement>('[data-cell]')];
let state: Snapshot | undefined;
let synchronized = false;

function render() {
    for (const [index, button] of cells.entries()) {
        button.textContent = state?.board[index] ?? '·';
        button.disabled = !state || !synchronized || client.state !== 'open' || state.players.length !== 2 ||
            Boolean(state.result) || state.you !== state.turn || state.board[index] !== null;
    }
    if (state) {
        const outcome = state.result === 'draw' ? 'Draw!' : state.result ? `${state.result} wins!` : `Turn: ${state.turn}`;
        status.textContent = `Room ${state.room} · You: ${state.you} · ${outcome} · Online: ${state.online.join(', ')}`;
        form.hidden = true;
    }
}
async function send(operation: () => Promise<unknown>) {
    try {
        if (client.state !== 'open') throw new Error('Wait for the connection, then try again.');
        await operation();
    } catch (error) { notice.textContent = (error as Error).message; }
}
client.onAny(message => { void protocol.parse(JSON.stringify(message)).then(event => {
    if (event.type === 'state') { state = event.payload; synchronized = true; notice.textContent = ''; render(); }
    else if (event.type === 'notice') notice.textContent = event.payload.text;
    else if (event.type === 'error') notice.textContent = event.error.message;
}).catch(() => { notice.textContent = 'Invalid server response. Reload the page.'; }); });
client.onStateChange(value => {
    synchronized = false;
    connection.textContent = value === 'open' ? 'Connected' : `${value} — moves are paused`;
    render();
    if (value === 'open' && state) void send(() => protocol.send('resume', { room: state!.room }));
});
client.onError(() => { notice.textContent = 'Connection unavailable. If signed out, sign in again.'; });
form.addEventListener('submit', event => {
    event.preventDefault();
    void send(() => protocol.send('join', { room: String(new FormData(form).get('room')) }));
});
for (const [cell, button] of cells.entries()) button.addEventListener('click', () => {
    if (state) void send(() => protocol.send('move', { cell, revision: state!.revision }));
});
window.addEventListener('pagehide', () => client.dispose(), { once: true });
void client.connect().catch(() => { notice.textContent = 'Unable to connect. Sign in again or reload.'; });
```


`RedwebClient` owns the connection and reconnect lifecycle. The shared contract validates what is sent and received. On reconnect, the client asks to `resume` the same room; the server looks up the authenticated account's existing seat. It does not trust a client-supplied board or mark.

Buttons stay disabled until a fresh snapshot arrives. Disconnected moves are not queued. If a move's outcome is uncertain, the next snapshot reconciles the board instead of blindly replaying the move.

## Try it with two people

1. Sign in as Alice and Bob in separate browser profiles.
2. Enter the same room name. Alice is X if she joins first; Bob is O.
3. Take turns. Only the current player can choose an empty square.
4. Finish a row, column, or diagonal. Both browsers show the result and disable the board.
5. Close one browser. The other sees the account leave the online list.
6. Open it again, sign in if needed, and rejoin the same room. Its reserved seat and board return.
7. Use a new room name for a new game.

## Test the browser, not just synthetic HTTP

```sh
npm run test:browser
```

This opens installed Google Chrome **headed**, uses two isolated cookie contexts, submits incorrect and correct passwords through actual forms, plays a winning game, severs a real game connection to exercise resume, verifies disconnect presence, and signs out. There are no mocked transports and no long soak waits.

## What to add before a production launch

The README describes the important limits: SQLite-backed accounts, in-memory matches, public room names, reserved seats, bounded capacity and one Node process. Add durable match storage, private invitations if needed, abandoned-room cleanup, account recovery or an identity provider, TLS proxy abuse controls, expiry notifications, and operational monitoring. A tutorial should make those boundaries visible, not quietly market a sample as a production service.

You now have the same progression in one architecture: a decorated TSX page, reactive server state, a tested referee, typed socket handlers, and authenticated browser players.
