Persistent games: rooms survive server restarts by log replay

Every room is now an append-only JSONL file (data/rooms/CODE.jsonl):
a birth-certificate meta line, then every join, start, and command.
Because the engine is deterministic, seed + log IS the game — on boot
the server replays each file and reconstructs the exact state, hands,
deck order, and chronicle. Verified live: create, join, play, KILL
the server, restart ("restored 1 room(s) from disk"), rejoin with the
seat token — identical positions, deck count, and hand, with the
chronicle history redelivered redacted per player. The client
remembers its seat (name, room, token) in localStorage and walks back
to the table automatically on connect, clearing the memory if the
seat is stale; a quiet "leave table" control forgets it on purpose.
This is the foundation phase 2 (play-by-turn) sits on: games now wait
indefinitely for their players — and debugging restarts cost nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:46:23 -04:00
co-authored by Claude Fable 5
parent 89961bbb59
commit 7cfffb8ab5
6 changed files with 203 additions and 6 deletions
+6
View File
@@ -17,6 +17,7 @@ import {
createRoom,
getRoom,
joinRoom,
loadPersistedRooms,
redactFor,
runCommand,
startGame,
@@ -25,6 +26,7 @@ import {
} from "./rooms";
const port = Number(process.env.PORT ?? 8787);
loadPersistedRooms();
const wss = new WebSocketServer({ port });
interface Session {
@@ -102,6 +104,10 @@ wss.on("connection", (socket) => {
session.playerId = name;
session.roomId = room.id;
send(socket, { type: "seat", playerId: name, token: result.token });
// Rejoining a running game: replay the chronicle so far.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) });
}
broadcastRoomState(room);
break;
}
+71 -5
View File
@@ -1,6 +1,7 @@
// Game rooms: the server is the authority. Each room holds one GameState and
// the append-only command log (the seed + log IS the game — the basis for
// replays and async play). Clients get per-player redacted views and events.
// replays, async play, and crash recovery). Every join, start, and command is
// persisted; on boot, rooms are rebuilt by replaying their files.
import { randomBytes, randomInt } from "node:crypto";
import {
@@ -14,6 +15,7 @@ import {
type GameView,
type PlayerId,
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store";
export interface LoggedCommand {
seq: number;
@@ -29,6 +31,7 @@ export interface Room {
/** Per-player secrets: reclaiming a seat requires the matching token. */
tokens: Map<PlayerId, string>;
seed: number;
expansion: boolean;
state: GameState | null; // null until started
log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient)
@@ -54,11 +57,20 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
players: [hostId],
tokens: new Map([[hostId, token]]),
seed: randomInt(0, 0xffffffff),
expansion: false,
state: null,
log: [],
events: [],
};
rooms.set(room.id, room);
appendLine(room.id, {
kind: "meta",
id: room.id,
hostId,
hostToken: token,
seed: room.seed,
createdAt: new Date().toISOString(),
});
return { room, token };
}
@@ -81,11 +93,11 @@ export function joinRoom(
const fresh = randomBytes(16).toString("hex");
room.players.push(playerId);
room.tokens.set(playerId, fresh);
appendLine(room.id, { kind: "join", name: playerId, token: fresh });
return { token: fresh };
}
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
function startInMemory(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
const n = room.players.length;
if (n !== 2 && n !== 4) return { error: "supported player counts: 2 or 4" };
const { state, events } = createGame({
@@ -93,11 +105,20 @@ export function startGame(room: Room, expansion: boolean): { events: GameEvent[]
seed: room.seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
});
room.expansion = expansion;
room.state = state;
room.events.push(...events);
return { events };
}
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const result = startInMemory(room, expansion);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion });
return result;
}
export function runCommand(
room: Room,
playerId: PlayerId,
@@ -107,13 +128,15 @@ export function runCommand(
const result = applyCommand(room.state, playerId, command);
if (!result.ok) return { error: result.error };
room.state = result.state;
room.log.push({
const logged: LoggedCommand = {
seq: room.log.length,
playerId,
command,
at: new Date().toISOString(),
});
};
room.log.push(logged);
room.events.push(...result.events);
appendLine(room.id, { kind: "command", ...logged });
return { events: result.events };
}
@@ -124,3 +147,46 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[] {
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
try {
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
const room: Room = {
id,
hostId: meta.hostId,
players: [meta.hostId],
tokens: new Map([[meta.hostId, meta.hostToken]]),
seed: meta.seed,
expansion: false,
state: null,
log: [],
events: [],
};
for (const line of lines.slice(1)) {
if (line.kind === "join") {
room.players.push(line.name);
room.tokens.set(line.name, line.token);
} else if (line.kind === "start") {
const r = startInMemory(room, line.expansion);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "command") {
if (!room.state) throw new Error("command before start in log");
const result = applyCommand(room.state, line.playerId, line.command as Command);
if (!result.ok) throw new Error(`replay failed at seq ${line.seq}: ${result.error}`);
room.state = result.state;
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
room.events.push(...result.events);
}
}
rooms.set(id, room);
restored++;
} catch (e) {
console.error(`could not restore room ${id}:`, e);
}
}
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
}
+71
View File
@@ -0,0 +1,71 @@
// Durable rooms: one append-only JSONL file per room. The first line is the
// room's birth certificate; every later line is a join, a start, or a game
// command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing.
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
kind: "meta";
id: string;
hostId: string;
hostToken: string;
seed: number;
createdAt: string;
}
export interface JoinLine {
kind: "join";
name: string;
token: string;
}
export interface StartLine {
kind: "start";
expansion: boolean;
}
export interface CommandLine {
kind: "command";
seq: number;
playerId: string;
command: unknown;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`);
}
export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true });
}
export function appendLine(roomId: string, line: RoomLine): void {
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
}
/** Read every persisted room's lines, keyed by room id. */
export function readAllRooms(): Map<string, RoomLine[]> {
ensureDataDir();
const rooms = new Map<string, RoomLine[]>();
for (const file of readdirSync(DATA_DIR)) {
if (!file.endsWith(".jsonl")) continue;
const id = file.slice(0, -".jsonl".length);
try {
const lines = readFileSync(join(DATA_DIR, file), "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l) as RoomLine);
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
} catch (e) {
console.error(`skipping unreadable room file ${file}:`, e);
}
}
return rooms;
}
+11
View File
@@ -413,6 +413,7 @@
<span class="mast-sub">sixth edition</span>
{#if net.roomId}
<span class="mast-room">room <b>{net.roomId}</b></span>
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
{/if}
<span class="mast-status" class:offline={net.status !== "connected"}>
{net.status === "connected" ? "" : "reconnecting…"}
@@ -694,6 +695,16 @@
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-status { font-size: 0.8rem; color: #c98a2a; }
.mast-leave {
background: none;
border: none;
color: #8d8672;
font-family: "Archivo Narrow", sans-serif;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
}
.mast-leave:hover { color: #d8d2c0; }
.toast {
background: #6d2119;
+43 -1
View File
@@ -131,6 +131,8 @@ function humanize(e: GameEvent): string | null {
}
}
const SEAT_KEY = "wizwar-seat";
class Net {
status = $state<"disconnected" | "connected">("disconnected");
roomId = $state<string | null>(null);
@@ -144,12 +146,27 @@ class Net {
private ws: WebSocket | null = null;
private token: string | null = null;
private roomIdPending: string | null = null;
connect(): void {
if (this.ws) return;
const ws = new WebSocket(SERVER_URL);
this.ws = ws;
ws.onopen = () => (this.status = "connected");
ws.onopen = () => {
this.status = "connected";
// A remembered seat means a game to walk back to (surviving reloads
// AND server restarts — the server replays the room from disk).
const saved = localStorage.getItem(SEAT_KEY);
if (saved && !this.roomId) {
try {
const seat = JSON.parse(saved) as { name: string; roomId: string; token: string };
this.you = seat.name;
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
} catch { localStorage.removeItem(SEAT_KEY); }
}
};
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
@@ -160,9 +177,17 @@ class Net {
switch (msg.type) {
case "seat":
this.token = msg.token;
if (this.roomIdPending || this.roomId) {
localStorage.setItem(SEAT_KEY, JSON.stringify({
name: msg.playerId, roomId: this.roomIdPending ?? this.roomId, token: msg.token,
}));
}
break;
case "room":
this.roomId = msg.roomId;
localStorage.setItem(SEAT_KEY, JSON.stringify({
name: this.you, roomId: msg.roomId, token: this.token,
}));
this.players = msg.players;
this.hostId = msg.hostId;
this.started = msg.started;
@@ -177,6 +202,10 @@ class Net {
}
break;
case "error":
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
this.roomIdPending = null;
}
this.error = msg.message;
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
@@ -195,9 +224,22 @@ class Net {
join(roomId: string, name: string): void {
this.you = name;
this.roomIdPending = roomId.toUpperCase();
this.send({ type: "join", roomId, name, token: this.token });
}
/** Forget the remembered seat and return to the lobby. */
leave(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.log = [];
this.token = null;
}
start(expansion: boolean): void {
this.send({ type: "start", expansion });
}