# 3. Connect authenticated players to the rules

Now represent a connected browser as a server-side player. Add `src/players.ts`:


```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`:


```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.
