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.
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
- Sign in as Alice and Bob in separate browser profiles.
- Enter the same room name. Alice is X if she joins first; Bob is O.
- Take turns. Only the current player can choose an empty square.
- Finish a row, column, or diagonal. Both browsers show the result and disable the board.
- Close one browser. The other sees the account leave the online list.
- Open it again, sign in if needed, and rejoin the same room. Its reserved seat and board return.
- Use a new room name for a new game.
Test the browser, not just synthetic HTTP
npm run test:browserThis 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.
Download the complete project Setup, optional configuration, and limits