2. Define player commands

The game needs three commands: join, move, and resume. Define that vocabulary before opening a socket so malformed input never reaches the rules.

Create src/command-schemas.ts:

ts
import { z } from 'zod';

export const version = '1';
export const room = z.string().regex(/^[a-z0-9-]{1,32}$/);
export const commands = {
    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(),
};

The room schema keeps identifiers small and predictable. A move carries both the chosen cell and the board revision from Step 1. Zod validates untrusted network data and supplies the inferred TypeScript payload types.

Next add the outgoing snapshot and event schemas in src/messages.ts, then create the shared contract in src/contract.ts:

ts
import { z } from 'zod';

import { commands, room } from './command-schemas.js';
export { version } from './command-schemas.js';
export const messages = {
    ...commands,
    state: 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(),
    notice: z.object({ text: z.string() }).strict(),
};

type Payloads = { [Key in keyof typeof messages]: z.infer<typeof messages[Key]> };
export type Snapshot = Payloads['state'];
export type ClientEvents = Pick<Payloads, 'join' | 'move' | 'resume'>;
// Protocol failures are error envelopes, not an application payload schema.
export type ServerEvents = Pick<Payloads, 'state' | 'notice'> & { error: never; 'redweb:result': null };
ts
import { defineSocketContract } from 'redweb/contract';
import { messages, version } from './messages.js';

export const match = defineSocketContract(version, messages);

The /match URL will select the game service. The message type will select exactly one handler. We never add a second action switch inside a generic handler.

The same contract describes both sides: server handlers receive validated payloads, and optional non-HTML clients get typed events and requests. Redweb's generated page client will use the same handler metadata later without handwritten browser transport code.

Checkpoint: add schema tests for valid and invalid room names, cells, revisions, snapshots, and notices. Run npx tsc --noEmit again. We now have rules and a protocol, but still no listener or UI.

Download the complete project Setup, optional configuration, and limits