4. Give the game a socket route
HTTP answers a request. A WebSocket stays open so either side can speak. The URL selects the service; the message type selects a handler.
For this game the URL is /match?redwebVersion=1. A request looks like:
{ "v": "1", "type": "move", "payload": { "cell": 4, "revision": 0 } }There is no message.action dispatcher. There are separate Join, Move, and Resume handlers.
import { defineSocketContract } from 'redweb/contract';
import { z } from 'zod';
const room = z.string().regex(/^[a-z0-9-]{1,32}$/);
export const snapshot = z.object({
board: z.array(z.enum(['X', 'O']).nullable()).length(9),
players: z.array(z.string()).max(2), revision: z.number().int().min(0).max(9),
result: z.enum(['X', 'O', 'draw']).nullable(), turn: z.enum(['X', 'O']), you: z.enum(['X', 'O']),
online: z.array(z.string()).max(2), room,
}).strict();
export type Snapshot = z.infer<typeof snapshot>;
export const match = defineSocketContract('1', {
join: z.object({ room }).strict(),
resume: z.object({ room }).strict(),
move: z.object({ cell: z.number().int().min(0).max(8), revision: z.number().int().min(0).max(9) }).strict(),
state: snapshot,
notice: z.object({ text: z.string() }).strict(),
});The contract is shared by server and client. Zod validates payloads and gives TypeScript their types. An invalid cell is a malformed command; a valid cell played out of turn is a game-rule rejection. The former is a protocol error; the latter returns a recoverable notice without pretending the move succeeded.
import { SocketRoute, type RedWebSocket, type RedWebRequest } from 'redweb';
import { match } from './contract.js';
import { Games } from './game.js';
export function matchRoute(account: (request: Pick<RedWebRequest, 'headers'>) => string | undefined,
origins: (origin: string | undefined, request: import('node:http').IncomingMessage) => boolean) {
const games = new Games();
const members = new Map<RedWebSocket, { room: string; account: string }>();
const identity = (socket: RedWebSocket) => {
const current = account(socket.context!.request);
if (!current || current !== socket.context!.principal) {
socket.close(1008, 'Sign in again.');
throw new Error('Sign in again.');
}
return current;
};
async function publish(room: string, sender?: RedWebSocket, requestId?: string) {
const peers = [...members].filter(([, member]) => member.room === room);
const valid = peers.filter(([socket, member]) => {
if (socket.readyState === 1 && account(socket.context!.request) === member.account) return true;
socket.close(1008, 'Sign in again.');
return false;
});
const online = [...new Set(valid.map(([, member]) => member.account))];
await Promise.all(valid.map(([socket, member]) => match.send(socket, 'state', {
...games.resume(room, member.account).snapshot(member.account), online, room,
}, socket === sender ? { requestId } : undefined)));
}
async function attempt(socket: RedWebSocket, requestId: string | undefined, operation: () => string) {
try { await publish(operation(), socket, requestId); }
catch (error) { await match.send(socket, 'notice', { text: (error as Error).message }, { requestId }); }
}
function enter(socket: RedWebSocket, room: string, resume: boolean) {
const user = identity(socket);
const previous = members.get(socket);
if (previous && previous.room !== room) throw new Error('Reload before joining another room.');
if (resume) games.resume(room, user);
else games.join(room, user);
members.set(socket, { room, account: user });
return room;
}
const Join = match.handler('join', (socket, { room }, message) => attempt(socket, message.requestId, () => enter(socket, room, false)));
const Resume = match.handler('resume', (socket, { room }, message) => attempt(socket, message.requestId, () => enter(socket, room, true)));
const Move = match.handler('move', (socket, { cell, revision }, message) => attempt(socket, message.requestId, () => {
const user = identity(socket);
const member = members.get(socket);
if (!member) throw new Error('Join a room first.');
games.resume(member.room, user).move(user, cell, revision);
return member.room;
}));
return class MatchRoute extends SocketRoute {
constructor() {
super({ path: '/match', handlers: [Join, Move, Resume], protocol: match.protocol,
admission: { authenticate: request => account(request) ?? false, origins },
orderedMessages: true, allowDuplicateConnections: true, logger: null,
heartbeat: { intervalMs: 15000, timeoutMs: 10000 },
websocketOptions: { maxPayload: 4096 },
limits: { maxConnections: 100, maxPendingMessages: 16, maxBufferedBytes: 65536,
messageRate: { capacity: 20, refillPerSecond: 5, action: 'disconnect' } },
});
}
async connectionCloseCallback(socket: RedWebSocket) {
const member = members.get(socket);
members.delete(socket);
if (member) await publish(member.room);
}
};
}matchRoute() creates an isolated room collection and captures the application's identity lookup. The returned MatchRoute is the ordinary route class registered with defineApp. This small factory keeps independent test applications from accidentally sharing matches.
Every command obtains the account from the authenticated socket context and current session. The payload cannot nominate another user. Publishing sends each member their own you mark and rechecks sessions before sending. The close callback updates presence. A second tab for one account does not become a third player or make that account appear offline when only one of its tabs closes.
Checkpoint: npm test connects real clients to this route and attempts both valid and hostile traffic. orderedMessages orders one socket's commands; the synchronous rules still enforce the shared game's turn and revision across sockets.
Download the complete project Setup, optional configuration, and limits