@tabledeck/game-room
v0.1.1
Published
Shared Durable Object infrastructure and React hooks for tabledeck.us multiplayer games
Readme
@tabledeck/game-room
Shared Durable Object infrastructure and React hooks for tabledeck.us multiplayer games.
Provides the complete WebSocket plumbing, player-join coordination, and persistent state management so each new game only needs to implement game-specific logic.
What's included
Server (@tabledeck/game-room/server) — Cloudflare Workers / Durable Objects:
BaseGameRoomDO— abstract DO base class with WebSocket hibernation, game lifecycle, and state persistencereadGuestCookie/makeGuestCookieHeader— parse and write the per-game guest identity cookie
Client (@tabledeck/game-room/client) — React / browser:
useGameWebSocket— manages the WS connection with automatic reconnect and stale-closure-safe message routing
Installation
npm install @tabledeck/game-roomPeer dependencies (must be in your project already):
npm install @cloudflare/workers-types react react-routerQuick start
1. Implement BaseGameRoomDO
// workers/game-room.ts
import { BaseGameRoomDO } from "@tabledeck/game-room/server";
interface MyState {
players: Array<{ seat: number; name: string; score: number } | null>;
phase: "waiting" | "playing" | "finished";
}
interface MySettings {
maxPlayers: number; // required by BaseSettings
roundCount: number;
}
export class GameRoomDO extends BaseGameRoomDO<MyState, MySettings, Env> {
protected initializeState(settings: MySettings): MyState {
return {
players: Array(settings.maxPlayers).fill(null),
phase: "waiting",
};
}
protected serializeState(state: MyState) {
return {
players: state.players.filter(Boolean),
phase: state.phase,
};
}
protected deserializeState(data: Record<string, unknown>): MyState {
return data as MyState;
}
protected isPlayerSeated(state: MyState, seat: number) {
return state.players[seat] != null;
}
protected getPlayerName(state: MyState, seat: number) {
return state.players[seat]?.name ?? null;
}
protected seatPlayer(state: MyState, seat: number, name: string): MyState {
const players = [...state.players];
players[seat] = { seat, name, score: 0 };
return { ...state, players };
}
protected getSeatedCount(state: MyState) {
return state.players.filter(Boolean).length;
}
protected async onAllPlayersSeated() {
// Deal cards, start first round, etc.
this.gameState!.phase = "playing";
await this.persistState();
this.broadcast(JSON.stringify({ type: "game_started" }));
}
protected async onGameMessage(ws: WebSocket, rawMsg: unknown, seat: number) {
const msg = rawMsg as { type: string };
if (msg.type === "make_move") {
// ... validate, apply, broadcast
}
}
}2. Add the DO to wrangler.toml
[[durable_objects.bindings]]
name = "MY_GAME_ROOM"
class_name = "GameRoomDO"
[[migrations]]
tag = "v1"
new_classes = ["GameRoomDO"]3. Create the game from your worker's fetch handler
// On POST /game (create new game):
const doId = env.MY_GAME_ROOM.idFromName(gameId);
const stub = env.MY_GAME_ROOM.get(doId);
await stub.fetch(new Request("http://internal/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings, gameId }),
}));4. Handle joins in your HTTP action
// React Router action — when a player submits the name modal:
import { readGuestCookie, makeGuestCookieHeader } from "@tabledeck/game-room/server";
export async function action({ params, request, context }: Route.ActionArgs) {
const { gameId } = params;
const body = await request.json() as { guestName?: string };
if (body.guestName) {
// 1. Write to your DB and get the assigned seat
const seat = await assignSeatInDB(gameId, body.guestName);
// 2. Notify the DO so it can broadcast player_joined to connected clients
const stub = getGameRoomStub(env, gameId);
await stub.fetch(new Request("http://internal/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seat, name: body.guestName }),
}));
// 3. Set the identity cookie so the player is recognised on reload
return data(
{ seat, name: body.guestName },
{ headers: { "Set-Cookie": makeGuestCookieHeader(`game_${gameId}`, seat, body.guestName) } },
);
}
}5. Connect on the client
// app/routes/game.$gameId.tsx
import { useGameWebSocket } from "@tabledeck/game-room/client";
import { useCallback } from "react";
export default function GamePage({ loaderData }) {
const { gameId, initialSeat, initialName } = loaderData;
const [mySeat, setMySeat] = useState(initialSeat);
const [myName, setMyName] = useState(initialName);
const [players, setPlayers] = useState(loaderData.players);
const { send } = useGameWebSocket({
gameId,
seat: mySeat,
name: myName,
onMessage: useCallback((msg: unknown) => {
const m = msg as { type: string };
switch (m.type) {
case "game_state":
// hydrate all state from (m as any).state
break;
case "player_joined":
setPlayers((prev) => [...prev, { seat: (m as any).seat, name: (m as any).name }]);
break;
// ... your game messages
}
}, []),
});
return <GameBoard send={send} players={players} />;
}Abstract methods reference
| Method | Purpose |
|---|---|
| initializeState(settings) | Create a fresh game state from settings |
| serializeState(state) | JSON-safe representation for storage and WebSocket transport (strip private data) |
| deserializeState(data) | Reconstruct state from stored JSON |
| isPlayerSeated(state, seat) | Check if a seat is occupied |
| getPlayerName(state, seat) | Return the display name at a seat, or null |
| seatPlayer(state, seat, name) | Return new state with the seat filled (do not mutate) |
| getSeatedCount(state) | Count occupied seats |
| onAllPlayersSeated() | Called once when the last seat fills — start the game |
| onGameMessage(ws, msg, seat, playerName) | Route game-specific WebSocket messages |
Optional overrides
| Method | Default | Override when |
|---|---|---|
| getPrivateStateForSeat(seat) | {} | You need to send per-player hidden data (hand of cards, tile rack, …) alongside game_state |
| onPlayerDisconnected(seat) | no-op | You want to mark a player as disconnected in your state |
Cookie utilities
import { readGuestCookie, makeGuestCookieHeader } from "@tabledeck/game-room/server";
// In a loader — re-identify a returning player:
const identity = readGuestCookie(request, `game_${gameId}`);
// → { seat: 2, name: "Alice" } | null
// In an action — set the cookie after a successful join:
const header = makeGuestCookieHeader(`game_${gameId}`, seat, name);
// → "game_abc123=2:Alice; Path=/; Max-Age=86400; SameSite=Lax"Further reading
- Building a new game — full step-by-step walkthrough
- WebSocket message protocol — base message types reference
- CLAUDE.md — guide for AI agents working on this package
