3. Connect authenticated players to the rules
Now represent a connected browser as a server-side player. Add src/players.ts:
import { connectedClients, type RedWebRequest } from 'redweb';
import { Games, GameError } from './game.js';
import type { Snapshot } from './messages.js';
import { rawClient } from './raw-client.js';
export interface GamePageState { game: Snapshot | null; notice: string; }
export type AccountLookup = (request: Pick<RedWebRequest, 'headers'>) => string | undefined;
export function gamePlayers(games: Games, account: AccountLookup, page: () => new () => GamePageState) {
return connectedClients<GamePageState, string>({
identity: context => account(context.request),
page,
project: (player, room, online) => ({
game: { ...games.resume(room, player.identity).snapshot(player.identity), room, online: [...online] },
notice: '',
}),
reject: error => error instanceof GameError ? error.message : undefined,
errorState: notice => ({ notice }),
raw: rawClient,
});
}connectedClients owns connection membership, deduplicated presence, reconnect-safe page lookup, and fan-out. Your application still owns identity, room access, game state, and the state each player may see.
The project callback builds a private snapshot for one verified identity. It never trusts an account name supplied in a message. Expected GameError text is safe to display; unexpected failures stay private.
Add src/handlers.ts:
import type { ConnectedClients } from 'redweb';
import { match as contract } from './contract.js';
import { Games, GameError } from './game.js';
import type { GamePageState } from './players.js';
export function gameHandlers(games: Games, players: ConnectedClients<GamePageState, string>) {
const match = players.bind(contract);
const Join = match.handler('join', async (player, { room }) => {
if (player.rooms.some(previous => previous !== room)) {
throw new GameError('Reload before joining another room.');
}
await player.join(room, () => games.join(room, player.identity));
});
const Resume = match.handler('resume', async (player, { room }) => {
if (player.rooms.some(previous => previous !== room)) {
throw new GameError('Reload before joining another room.');
}
await player.join(room, () => games.resume(room, player.identity));
});
const Move = match.handler('move', (player, { cell, revision }) => {
games.resume(player.room, player.identity).move(player.identity, cell, revision);
});
return { Join, Move, Resume };
}Each command is one small decision. Join assigns a seat, Resume confirms an existing seat, and Move delegates to the authoritative model. player.join(room, commit) commits connection membership only when the synchronous game operation succeeds.
There is no application-owned socket map, message.action switch, fan-out loop, request ID, or disconnect callback. Successful handlers cause Redweb to refresh the affected players' server-rendered state.
Checkpoint: test each handler with real route connections: valid joins, room capacity, resuming a reserved seat, illegal moves, disconnect cleanup, duplicate tabs, and rejected identity changes. The final download contains those network tests; keep domain-rule unit tests separate from transport acceptance.
Download the complete project Setup, optional configuration, and limits