create-siltrun 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/index.mjs +98 -0
  2. package/package.json +18 -0
  3. package/templates/mass-grid/AGENTS.md +106 -0
  4. package/templates/mass-grid/README.md +59 -0
  5. package/templates/mass-grid/_gitignore +4 -0
  6. package/templates/mass-grid/index.html +22 -0
  7. package/templates/mass-grid/package.json +31 -0
  8. package/templates/mass-grid/room.test.ts +214 -0
  9. package/templates/mass-grid/room.ts +530 -0
  10. package/templates/mass-grid/skills/authoritative-tick.md +116 -0
  11. package/templates/mass-grid/skills/compact-snapshots.md +110 -0
  12. package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
  13. package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
  14. package/templates/mass-grid/skills/territory-capture.md +115 -0
  15. package/templates/mass-grid/src/Game.tsx +174 -0
  16. package/templates/mass-grid/src/main.tsx +9 -0
  17. package/templates/mass-grid/tsconfig.json +15 -0
  18. package/templates/mass-grid/vite.config.ts +9 -0
  19. package/templates/minimal/README.md +39 -0
  20. package/templates/minimal/_gitignore +4 -0
  21. package/templates/minimal/index.html +15 -0
  22. package/templates/minimal/package.json +30 -0
  23. package/templates/minimal/room.ts +30 -0
  24. package/templates/minimal/src/Game.tsx +71 -0
  25. package/templates/minimal/src/main.tsx +9 -0
  26. package/templates/minimal/tsconfig.json +15 -0
  27. package/templates/minimal/vite.config.ts +7 -0
  28. package/templates/tower-defense/AGENTS.md +132 -0
  29. package/templates/tower-defense/README.md +53 -0
  30. package/templates/tower-defense/_gitignore +4 -0
  31. package/templates/tower-defense/index.html +15 -0
  32. package/templates/tower-defense/package.json +31 -0
  33. package/templates/tower-defense/room.test.ts +159 -0
  34. package/templates/tower-defense/room.ts +321 -0
  35. package/templates/tower-defense/skills/authoritative-tick.md +96 -0
  36. package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
  37. package/templates/tower-defense/skills/state-budget.md +79 -0
  38. package/templates/tower-defense/skills/two-lanes.md +66 -0
  39. package/templates/tower-defense/skills/waves-and-timing.md +79 -0
  40. package/templates/tower-defense/src/Game.tsx +227 -0
  41. package/templates/tower-defense/src/main.tsx +9 -0
  42. package/templates/tower-defense/tsconfig.json +15 -0
  43. package/templates/tower-defense/vite.config.ts +7 -0
  44. package/templates/turn-based-grid/AGENTS.md +183 -0
  45. package/templates/turn-based-grid/README.md +72 -0
  46. package/templates/turn-based-grid/_gitignore +4 -0
  47. package/templates/turn-based-grid/index.html +15 -0
  48. package/templates/turn-based-grid/package.json +30 -0
  49. package/templates/turn-based-grid/room.ts +309 -0
  50. package/templates/turn-based-grid/skills/play.md +97 -0
  51. package/templates/turn-based-grid/src/App.tsx +141 -0
  52. package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
  53. package/templates/turn-based-grid/src/brains.ts +112 -0
  54. package/templates/turn-based-grid/src/main.tsx +8 -0
  55. package/templates/turn-based-grid/src/stubAgent.ts +82 -0
  56. package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
  57. package/templates/turn-based-grid/src/substrate/map.ts +104 -0
  58. package/templates/turn-based-grid/src/substrate/types.ts +59 -0
  59. package/templates/turn-based-grid/src/view.ts +94 -0
  60. package/templates/turn-based-grid/tsconfig.json +15 -0
  61. package/templates/turn-based-grid/vite.config.ts +7 -0
@@ -0,0 +1,530 @@
1
+ // room.ts — a MASS-GRID territory-capture room (Paper.io / Splix.io genre).
2
+ //
3
+ // This file runs SERVER-SIDE inside a deterministic realm: browsers submit intent, this
4
+ // tick() decides truth, and every client receives the full authoritative state each tick.
5
+ // Edit it while the dev loop runs — it hot-reloads. Two things live here, separated:
6
+ //
7
+ // 1. THE DEMO-ROOM LIFECYCLE SHELL — genre-agnostic, COPIED VERBATIM from the tower-
8
+ // defense template. One always-warm room: drop-in join, take over an abandoned
9
+ // player or start fresh, ready-up → rolling rounds → a new game auto-starts. No
10
+ // lobby. Normative spec + invariants: skills/demo-room-lifecycle.md.
11
+ // 2. THE TERRITORY-CAPTURE GENRE — everything below the shell.
12
+ //
13
+ // Two ideas make this genre reach a CROWD in one room:
14
+ //
15
+ // • THE STATE SHAPE IS THE WIRE SHAPE. There is no separate "encoding" step — the
16
+ // object tick() returns is JSON-marshaled into ONE UDP datagram (~1200 bytes) and
17
+ // sent as-is. So you engineer the STATE to be compact: the grid rides as a run-length
18
+ // string (`g`), players ride as a flat SLOT-INDEXED array (`players`), trails ride as
19
+ // turn-point polylines — never a per-cell or per-id object map. Naive id-keyed object
20
+ // state is the trap that blows the datagram at ~8 players; flat index-keyed state is
21
+ // what lets one datagram carry a whole board. (See skills/compact-snapshots.md.)
22
+ //
23
+ // • SLOW TICK INSIDE A FAST CLOCK. The host ticks 60Hz, but a grid game wants ~6 steps/
24
+ // sec — so the sim only ADVANCES every STEP_TICKS ticks; ticks between just absorb
25
+ // input. Cheap, and it gives the classic grid cadence.
26
+ //
27
+ // MOVE INPUT RIDES THE RELIABLE LANE (`room.events.send`), NOT presence datagrams. A slow
28
+ // discrete grid game wants ORDERED, no-drop turns — a dropped turn is a death. The genre→
29
+ // lane rule: fast-continuous games use the presence/datagram lane; slow-discrete grid
30
+ // games use the reliable events lane. (See skills/genre-lane-mapping.md.)
31
+ import type { Room, Input, Ctx } from "@siltrun/room";
32
+
33
+ // ════════════════════════════════════════════════════════════════════════════════════
34
+ // ══ DEMO-ROOM LIFECYCLE SHELL — genre-agnostic. Copy verbatim. ══
35
+ // ══ Normative spec + invariants: skills/demo-room-lifecycle.md ══
36
+ // ════════════════════════════════════════════════════════════════════════════════════
37
+
38
+ export type Phase = "gathering" | "playing" | "gameover";
39
+
40
+ /** A fixed seat at the table. `id` is the peer holding it (null = empty). A seat whose
41
+ * peer left MID-GAME is kept (`abandoned`) so a newcomer can take it over — its game
42
+ * data (towers, etc.) keeps running. The original can also reclaim it by rejoining. */
43
+ export type Seat = { id: string | null; ready: boolean; abandoned: boolean };
44
+
45
+ /** Shell-owned meta. Namespaced under one key so the genre state stays cleanly separable
46
+ * and this whole block copies without collision. */
47
+ export type Shell = {
48
+ phase: Phase;
49
+ seats: Seat[];
50
+ round: number; // increments each game start
51
+ phaseSince: number; // ctx.tick when the current phase began — drives countdowns
52
+ over: boolean; // the GENRE sets this true to end a game (playing → gameover)
53
+ };
54
+
55
+ export type ShellOpts = {
56
+ seats: number; // fixed table size
57
+ minPlayers: number; // ready seats required to start a game
58
+ gameoverTicks: number; // how long the gameover screen holds before auto-reset
59
+ };
60
+
61
+ export function initShell(o: ShellOpts): Shell {
62
+ return {
63
+ phase: "gathering",
64
+ seats: Array.from({ length: o.seats }, () => ({ id: null, ready: false, abandoned: false })),
65
+ round: 0,
66
+ phaseSince: 0,
67
+ over: false,
68
+ };
69
+ }
70
+
71
+ /** Seat a peer: resume your own seat (reconnect), else claim an empty seat, else TAKE
72
+ * OVER an abandoned seat (adopt its in-progress game data). No seat available →
73
+ * spectator (returns -1); a mid-game spectator waits for the next gathering. */
74
+ function seatFor(shell: Shell, id: string): number {
75
+ const own = shell.seats.findIndex((s) => s.id === id);
76
+ if (own >= 0) { shell.seats[own].abandoned = false; return own; } // reconnect
77
+ const empty = shell.seats.findIndex((s) => s.id === null);
78
+ if (empty >= 0) { shell.seats[empty] = { id, ready: false, abandoned: false }; return empty; }
79
+ const aband = shell.seats.findIndex((s) => s.abandoned);
80
+ if (aband >= 0) { shell.seats[aband].id = id; shell.seats[aband].abandoned = false; return aband; } // takeover
81
+ return -1; // spectator
82
+ }
83
+
84
+ /** Runs FIRST in tick(). Applies membership (join/leave) + `ready` events, then opens a
85
+ * round when enough seats are ready. Returns "start" on the tick a game begins (the genre
86
+ * seeds a fresh game on that signal); the caller passes back which seats went live. */
87
+ export function openRound(shell: Shell, inputs: Input[], ctx: Ctx, o: ShellOpts): "start" | null {
88
+ for (const ev of inputs) {
89
+ if (ev.kind === "join") {
90
+ seatFor(shell, ev.id);
91
+ } else if (ev.kind === "leave") {
92
+ const i = shell.seats.findIndex((s) => s.id === ev.id);
93
+ if (i < 0) continue;
94
+ if (shell.phase === "playing") shell.seats[i].abandoned = true; // keep data, allow takeover
95
+ else shell.seats[i] = { id: null, ready: false, abandoned: false }; // no game to preserve → free it
96
+ } else if (ev.kind === "event" && isReady(ev.data)) {
97
+ const i = shell.seats.findIndex((s) => s.id === ev.from);
98
+ if (i >= 0 && shell.phase === "gathering") shell.seats[i].ready = true;
99
+ }
100
+ }
101
+ if (shell.phase === "gathering") {
102
+ const readyCount = shell.seats.filter((s) => s.id && s.ready && !s.abandoned).length;
103
+ if (readyCount >= o.minPlayers) {
104
+ shell.phase = "playing";
105
+ shell.round += 1;
106
+ shell.phaseSince = ctx.tick;
107
+ shell.over = false;
108
+ for (const s of shell.seats) s.ready = false;
109
+ return "start";
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** Runs LAST in tick(). Advances playing→gameover (when the genre set `over`) and
116
+ * gameover→gathering (after the countdown, freeing abandoned seats). Returns "reset" on
117
+ * the tick a fresh gathering begins (the genre clears its game state on that signal). */
118
+ export function closeRound(shell: Shell, ctx: Ctx, o: ShellOpts): "reset" | null {
119
+ if (shell.phase === "playing" && shell.over) {
120
+ shell.phase = "gameover";
121
+ shell.phaseSince = ctx.tick;
122
+ return null;
123
+ }
124
+ if (shell.phase === "gameover" && ctx.tick - shell.phaseSince >= o.gameoverTicks) {
125
+ shell.phase = "gathering";
126
+ shell.phaseSince = ctx.tick;
127
+ shell.over = false;
128
+ for (const s of shell.seats) {
129
+ if (s.abandoned) { s.id = null; s.abandoned = false; } // its player is gone
130
+ s.ready = false;
131
+ }
132
+ return "reset";
133
+ }
134
+ return null;
135
+ }
136
+
137
+ function isReady(data: unknown): boolean {
138
+ return !!data && typeof data === "object" && (data as { type?: unknown }).type === "ready";
139
+ }
140
+
141
+ // ════════════════════════════════════════════════════════════════════════════════════
142
+ // ══ END DEMO-ROOM LIFECYCLE SHELL ══
143
+ // ════════════════════════════════════════════════════════════════════════════════════
144
+
145
+ // ── TERRITORY-CAPTURE GENRE ───────────────────────────────────────────────────
146
+
147
+ // tuneables. SEATS × GRID × TRAIL-LENGTH is a DATAGRAM-BUDGET decision, not a taste one:
148
+ // the whole State is JSON-marshaled into ONE ~1200-byte datagram every tick, so every
149
+ // per-player and per-cell byte is multiplied by the crowd. The budget breaks down as:
150
+ // • the copied lifecycle shell — ~55B per SEAT (fixed; can't shrink the verbatim block),
151
+ // • the grid RLE — grows with cells AND territory fragmentation; row-major RLE is the
152
+ // dominant, data-dependent cost (jagged contested borders multiply runs),
153
+ // • the players — flat-encoded, but TRAILS are variable-length, so they are capped.
154
+ // These numbers were MEASURED, not guessed (room.test.ts + the probes behind it). Under a
155
+ // messy-realistic worst (all 6 seats trailing at the cap, jagged contested board), 20×20 ×
156
+ // 6 seats ≈ 1099B — fits with headroom. 28×28 broke it (~1400B); 8 seats never fits once
157
+ // players trail. VERTICALLY-STRIPED territory breaks the RLE at ANY size/seat count (the
158
+ // grid alone can exceed a datagram) — that is the JSON-snapshot ceiling, fixed only by
159
+ // binary/delta snapshots (out of scope for a demo). It degrades GRACEFULLY, not fatally:
160
+ // state datagrams are latest-wins + droppable, so an oversized tick is dropped and the
161
+ // client recovers on the next in-budget tick — a brief stutter, not a freeze. The math +
162
+ // scaling path is in skills/compact-snapshots.md; room.test.ts is the guard.
163
+ export const W = 20; // grid width (cells)
164
+ export const H = 20; // grid height
165
+ export const STEP_TICKS = 9; // 60Hz / 9 ≈ 6.7 sim-steps/sec
166
+ export const ROUND_TICKS = 60 * 63; // ~63s round (in 60Hz ticks)
167
+ export const RESPAWN_TICKS = 108; // ~1.8s dead → respawn (in 60Hz ticks)
168
+ export const MAX_TRAIL_TURNS = 6; // trail turn-points cap → bounds the datagram AND is a
169
+ // genre mechanic: over-extend (too many bends out in the
170
+ // open) and you die. Straight trails are cheap (1 turn).
171
+ const SPAWN = 3; // starting blob is SPAWN×SPAWN
172
+ const SHELL_OPTS: ShellOpts = { seats: 6, minPlayers: 2, gameoverTicks: 200 };
173
+
174
+ // direction: 0=up 1=right 2=down 3=left
175
+ const DX = [0, 1, 0, -1];
176
+ const DY = [-1, 0, 1, 0];
177
+ const opposite = (d: number) => (d + 2) % 4;
178
+
179
+ /** Per-seat game data. This is the WORKING form used inside tick() — readable objects.
180
+ * It is NEVER stored in state directly (that would be the fat id/object-map trap). Instead
181
+ * tick() decodes the flat wire form (`pf`/`pt`) into these, mutates them, and re-encodes —
182
+ * exactly the pack/unpack discipline the grid uses. The state IS the wire; keep it flat. */
183
+ export type Player = {
184
+ x: number; // head cell x
185
+ y: number; // head cell y
186
+ d: number; // facing direction 0..3
187
+ nd: number; // buffered next direction (applied on the next step)
188
+ a: 0 | 1; // alive (0 = dead, awaiting respawn)
189
+ t: number[]; // trail turn-points, flat [x0,y0,x1,y1,…]; empty when home
190
+ rs: number; // respawn-at ctx.tick when dead (0 when alive)
191
+ };
192
+
193
+ export type State = {
194
+ shell: Shell;
195
+ w: number;
196
+ h: number;
197
+ g: string; // row-major RLE of cell ownership (0 empty, slot+1 = owner)
198
+ // players ride FLAT, index-keyed by seat slot — the compact wire form:
199
+ pf: number[]; // scalars, STRIDE per slot: [alive(0/1/2), x, y, d, nd, rs] (0 = empty seat)
200
+ pt: number[][]; // trail turn-points per slot (flat [x0,y0,…]); [] when home/empty
201
+ win: number; // winning slot of the last round (-1 = none) — for the gameover screen
202
+ };
203
+
204
+ /** A reliable-lane event: a turn (0..3) or ready-up (`{type:"ready"}`, handled by shell). */
205
+ export type Cmd = { turn: number };
206
+
207
+ const STRIDE = 6; // pf scalars per slot: [alive, x, y, d, nd, rs]
208
+ // alive marker in pf: 0 = empty seat (no player), 1 = alive, 2 = dead (awaiting respawn)
209
+
210
+ /** Decode the flat wire form into readable working players (index === seat slot). */
211
+ export function readPlayers(state: State): (Player | null)[] {
212
+ const out: (Player | null)[] = [];
213
+ for (let slot = 0; slot < state.pf.length / STRIDE; slot++) {
214
+ const b = slot * STRIDE;
215
+ const mark = state.pf[b]!;
216
+ if (mark === 0) { out.push(null); continue; }
217
+ out.push({
218
+ a: mark === 1 ? 1 : 0,
219
+ x: state.pf[b + 1]!, y: state.pf[b + 2]!, d: state.pf[b + 3]!,
220
+ nd: state.pf[b + 4]!, rs: state.pf[b + 5]!,
221
+ t: state.pt[slot] ?? [],
222
+ });
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /** Encode working players back into the flat wire form. */
228
+ export function writePlayers(state: State, players: (Player | null)[]): void {
229
+ const pf: number[] = [];
230
+ const pt: number[][] = [];
231
+ for (const pl of players) {
232
+ if (!pl) { pf.push(0, 0, 0, 0, 0, 0); pt.push([]); continue; }
233
+ pf.push(pl.a ? 1 : 2, pl.x, pl.y, pl.d, pl.nd, pl.rs);
234
+ pt.push(pl.t);
235
+ }
236
+ state.pf = pf;
237
+ state.pt = pt;
238
+ }
239
+
240
+ // ── grid RLE codec ────────────────────────────────────────────────────────────
241
+ // A run is "<value36>.<len36>"; runs joined by ",". Value is the owner (0 empty, slot+1).
242
+ export function encodeGrid(cells: Int16Array): string {
243
+ const runs: string[] = [];
244
+ let cur = cells[0]!, len = 1;
245
+ for (let i = 1; i < cells.length; i++) {
246
+ if (cells[i] === cur) len++;
247
+ else { runs.push(cur.toString(36) + "." + len.toString(36)); cur = cells[i]!; len = 1; }
248
+ }
249
+ runs.push(cur.toString(36) + "." + len.toString(36));
250
+ return runs.join(",");
251
+ }
252
+ export function decodeGrid(rle: string, w: number, h: number): Int16Array {
253
+ const out = new Int16Array(w * h);
254
+ if (rle.length === 0) return out;
255
+ let i = 0;
256
+ for (const run of rle.split(",")) {
257
+ const dot = run.indexOf(".");
258
+ const val = parseInt(run.slice(0, dot), 36);
259
+ const len = parseInt(run.slice(dot + 1), 36);
260
+ out.fill(val, i, i + len);
261
+ i += len;
262
+ }
263
+ return out;
264
+ }
265
+
266
+ // ── trail geometry ────────────────────────────────────────────────────────────
267
+ // Expand a turn-point polyline (+ the live head) into the set of cell indices it covers.
268
+ // Consecutive points are always axis-aligned, so each segment is a straight walk.
269
+ export function trailCells(t: number[], headX: number, headY: number, w: number): number[] {
270
+ const pts: [number, number][] = [];
271
+ for (let i = 0; i < t.length; i += 2) pts.push([t[i]!, t[i + 1]!]);
272
+ pts.push([headX, headY]);
273
+ const seen = new Set<number>();
274
+ const out: number[] = [];
275
+ const push = (x: number, y: number) => {
276
+ const idx = y * w + x;
277
+ if (!seen.has(idx)) { seen.add(idx); out.push(idx); }
278
+ };
279
+ push(pts[0]![0], pts[0]![1]);
280
+ for (let i = 1; i < pts.length; i++) {
281
+ let [x, y] = pts[i - 1]!;
282
+ const [tx, ty] = pts[i]!;
283
+ const sx = Math.sign(tx - x), sy = Math.sign(ty - y);
284
+ let guard = 0;
285
+ while (x !== tx || y !== ty) {
286
+ x += sx; y += sy; push(x, y);
287
+ if (++guard > 4096) throw new Error(`trailCells non-axis-aligned segment [${pts[i-1]}]->[${pts[i]}] in [${t}] head[${headX},${headY}]`);
288
+ }
289
+ }
290
+ return out;
291
+ }
292
+
293
+ // ── flood-fill claim ──────────────────────────────────────────────────────────
294
+ // Close a loop: the trail cells become owned, then every cell the OUTSIDE border can't
295
+ // reach (without crossing the owner's territory) is enclosed → claimed. Captures empty
296
+ // pockets AND enemy cells the loop wrapped around. O(cells), on claim-events only.
297
+ export function claim(grid: Int16Array, w: number, h: number, owner: number, trail: number[]): void {
298
+ for (const c of trail) grid[c] = owner;
299
+ const outside = new Uint8Array(w * h);
300
+ const q: number[] = [];
301
+ const visit = (idx: number) => {
302
+ if (outside[idx] || grid[idx] === owner) return;
303
+ outside[idx] = 1;
304
+ q.push(idx);
305
+ };
306
+ for (let x = 0; x < w; x++) { visit(x); visit((h - 1) * w + x); }
307
+ for (let y = 0; y < h; y++) { visit(y * w); visit(y * w + w - 1); }
308
+ for (let head = 0; head < q.length; head++) {
309
+ const idx = q[head]!, x = idx % w, y = (idx / w) | 0;
310
+ if (x > 0) visit(idx - 1);
311
+ if (x < w - 1) visit(idx + 1);
312
+ if (y > 0) visit(idx - w);
313
+ if (y < h - 1) visit(idx + w);
314
+ }
315
+ for (let i = 0; i < grid.length; i++) if (grid[i] !== owner && !outside[i]) grid[i] = owner;
316
+ }
317
+
318
+ export function countCells(grid: Int16Array, owner: number): number {
319
+ let n = 0;
320
+ for (let i = 0; i < grid.length; i++) if (grid[i] === owner) n++;
321
+ return n;
322
+ }
323
+
324
+ // ── genre lifecycle glue ────────────────────────────────────────────────────────
325
+ function freshState(): State {
326
+ const s: State = {
327
+ shell: initShell(SHELL_OPTS),
328
+ w: W,
329
+ h: H,
330
+ g: encodeGrid(new Int16Array(W * H)),
331
+ pf: [],
332
+ pt: [],
333
+ win: -1,
334
+ };
335
+ writePlayers(s, Array.from({ length: SHELL_OPTS.seats }, () => null));
336
+ return s;
337
+ }
338
+
339
+ function spawn(grid: Int16Array, w: number, h: number, slot: number, rnd: () => number): Player {
340
+ const m = SPAWN;
341
+ const r = (SPAWN - 1) >> 1;
342
+ // find a spawn center whose SPAWN×SPAWN footprint is empty, so every player gets a real
343
+ // home (a player with no territory can never close a loop — it would be stuck). Retry a
344
+ // few times; fall back to the last pick if the board is crowded (rare at demo scale).
345
+ let cx = m, cy = m;
346
+ for (let attempt = 0; attempt < 12; attempt++) {
347
+ cx = m + Math.floor(rnd() * (w - 2 * m));
348
+ cy = m + Math.floor(rnd() * (h - 2 * m));
349
+ let clear = true;
350
+ for (let dy = -r; dy <= r && clear; dy++)
351
+ for (let dx = -r; dx <= r; dx++) if (grid[(cy + dy) * w + (cx + dx)] !== 0) { clear = false; break; }
352
+ if (clear) break;
353
+ }
354
+ const owner = slot + 1;
355
+ for (let dy = -r; dy <= r; dy++)
356
+ for (let dx = -r; dx <= r; dx++) {
357
+ const x = cx + dx, y = cy + dy;
358
+ if (x >= 0 && x < w && y >= 0 && y < h) grid[y * w + x] = owner;
359
+ }
360
+ return { x: cx, y: cy, d: 1, nd: 1, a: 1, t: [], rs: 0 };
361
+ }
362
+
363
+ function release(grid: Int16Array, owner: number): void {
364
+ for (let i = 0; i < grid.length; i++) if (grid[i] === owner) grid[i] = 0;
365
+ }
366
+
367
+ /** GENRE ← "start": seed a fresh board and spawn a player for every occupied seat. */
368
+ function startGame(state: State, players: (Player | null)[], grid: Int16Array, rnd: () => number): void {
369
+ grid.fill(0);
370
+ for (let slot = 0; slot < state.shell.seats.length; slot++) {
371
+ players[slot] = state.shell.seats[slot]!.id ? spawn(grid, state.w, state.h, slot, rnd) : null;
372
+ }
373
+ state.win = -1;
374
+ }
375
+
376
+ /** GENRE ← "reset": drop all game data (the board goes empty for the next gathering). */
377
+ function clearGame(state: State, players: (Player | null)[], grid: Int16Array): void {
378
+ grid.fill(0);
379
+ for (let slot = 0; slot < players.length; slot++) players[slot] = null;
380
+ state.win = -1;
381
+ }
382
+
383
+ /** Reconcile players to seats mid-game: a fresh arrival / takeover of an emptied slot gets
384
+ * a new player; a seat that emptied loses its player + territory. Abandoned seats keep
385
+ * their id (so their player + territory persist, frozen, awaiting takeover). */
386
+ function syncPlayers(state: State, players: (Player | null)[], grid: Int16Array, rnd: () => number): void {
387
+ for (let slot = 0; slot < state.shell.seats.length; slot++) {
388
+ const occupied = state.shell.seats[slot]!.id !== null;
389
+ if (occupied && !players[slot]) players[slot] = spawn(grid, state.w, state.h, slot, rnd);
390
+ else if (!occupied && players[slot]) { release(grid, slot + 1); players[slot] = null; }
391
+ }
392
+ }
393
+
394
+ // Advance the whole sim one grid-step. Mutates `grid` and players.
395
+ function simStep(state: State, players: (Player | null)[], grid: Int16Array, ctx: Ctx): void {
396
+ const { w, h } = state;
397
+ const seats = state.shell.seats;
398
+
399
+ // 1. apply buffered turns + move each alive, non-abandoned head one cell.
400
+ for (let slot = 0; slot < players.length; slot++) {
401
+ const pl = players[slot];
402
+ if (!pl || !pl.a || seats[slot]!.abandoned) continue; // abandoned players freeze until taken over
403
+ const prevDir = pl.d;
404
+ if (pl.nd !== opposite(pl.d)) pl.d = pl.nd; // no 180° reversal
405
+ const turned = pl.d !== prevDir; // did this tick change heading?
406
+ const ox = pl.x, oy = pl.y;
407
+ const nx = ox + DX[pl.d]!, ny = oy + DY[pl.d]!;
408
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h) { // ran into the wall → die
409
+ pl.a = 0; pl.rs = ctx.tick + RESPAWN_TICKS; release(grid, slot + 1); pl.t = [];
410
+ continue;
411
+ }
412
+ const owner = slot + 1;
413
+ const newIsOutside = grid[ny * w + nx] !== owner;
414
+ // Trail turn-points: consecutive points MUST be axis-aligned, so a corner is recorded
415
+ // only when the heading actually changed this tick (`turned`). The current cell (ox,oy)
416
+ // is that corner. On leaving home the first point anchors the trail to the territory edge.
417
+ if (newIsOutside) {
418
+ if (pl.t.length === 0) {
419
+ pl.t.push(ox, oy); // entry: anchor on the last owned cell
420
+ } else if (turned) {
421
+ if (pl.t.length >= MAX_TRAIL_TURNS * 2) { // over-extension death → bounds trail wire cost
422
+ pl.a = 0; pl.rs = ctx.tick + RESPAWN_TICKS; release(grid, slot + 1); pl.t = [];
423
+ continue;
424
+ }
425
+ pl.t.push(ox, oy); // record the corner
426
+ }
427
+ }
428
+ pl.x = nx; pl.y = ny;
429
+ }
430
+
431
+ // 2. collisions: a head sitting on ANY trail cell (not its own live head) cuts that
432
+ // trail's owner; two heads on one cell → both die.
433
+ const trailOwnerOf = new Int16Array(w * h).fill(-1);
434
+ for (let slot = 0; slot < players.length; slot++) {
435
+ const pl = players[slot];
436
+ if (!pl || !pl.a || pl.t.length === 0) continue;
437
+ for (const c of trailCells(pl.t, pl.x, pl.y, w)) if (c !== pl.y * w + pl.x) trailOwnerOf[c] = slot;
438
+ }
439
+ const kills = new Set<number>();
440
+ const headAt = new Map<number, number[]>();
441
+ for (let slot = 0; slot < players.length; slot++) {
442
+ const pl = players[slot];
443
+ if (!pl || !pl.a) continue;
444
+ const idx = pl.y * w + pl.x;
445
+ let arr = headAt.get(idx);
446
+ if (!arr) { arr = []; headAt.set(idx, arr); }
447
+ arr.push(slot);
448
+ if (trailOwnerOf[idx]! >= 0) kills.add(trailOwnerOf[idx]!);
449
+ }
450
+ for (const [, slots] of headAt) if (slots.length > 1) for (const s of slots) kills.add(s);
451
+ for (const slot of kills) {
452
+ const pl = players[slot];
453
+ if (!pl) continue;
454
+ pl.a = 0; pl.rs = ctx.tick + RESPAWN_TICKS; release(grid, slot + 1); pl.t = [];
455
+ }
456
+
457
+ // 3. claims: an alive player whose head re-entered its OWN territory with a live trail
458
+ // closes the loop.
459
+ for (let slot = 0; slot < players.length; slot++) {
460
+ const pl = players[slot];
461
+ if (!pl || !pl.a || pl.t.length === 0) continue;
462
+ const owner = slot + 1;
463
+ if (grid[pl.y * w + pl.x] === owner) {
464
+ claim(grid, w, h, owner, trailCells(pl.t, pl.x, pl.y, w));
465
+ pl.t = [];
466
+ }
467
+ }
468
+
469
+ // 4. respawns
470
+ for (let slot = 0; slot < players.length; slot++) {
471
+ const pl = players[slot];
472
+ if (pl && !pl.a && pl.rs > 0 && ctx.tick >= pl.rs && seats[slot]!.id !== null)
473
+ players[slot] = spawn(grid, w, h, slot, ctx.random);
474
+ }
475
+ }
476
+
477
+ function mostTerritory(players: (Player | null)[], grid: Int16Array): number {
478
+ let best = -1, bestN = -1;
479
+ for (let slot = 0; slot < players.length; slot++) {
480
+ if (!players[slot]) continue;
481
+ const n = countCells(grid, slot + 1);
482
+ if (n > bestN) { bestN = n; best = slot; }
483
+ }
484
+ return best;
485
+ }
486
+
487
+ export default {
488
+ init(): State {
489
+ return freshState();
490
+ },
491
+ tick(state = freshState(), inputs, ctx): State {
492
+ // unpack the compact wire form into readable working structures (state IS the wire)
493
+ const grid = decodeGrid(state.g, state.w, state.h);
494
+ const players = readPlayers(state);
495
+
496
+ // ── the lifecycle shell drives the room (runs FIRST) ──
497
+ if (openRound(state.shell, inputs, ctx, SHELL_OPTS) === "start") startGame(state, players, grid, ctx.random);
498
+
499
+ if (state.shell.phase === "playing") {
500
+ syncPlayers(state, players, grid, ctx.random); // mid-game join / takeover / departure
501
+
502
+ // buffer turn intents every tick; the sim consumes them on the next step
503
+ for (const ev of inputs) {
504
+ if (ev.kind !== "event") continue;
505
+ const turn = (ev.data as Partial<Cmd>)?.turn;
506
+ if (turn === undefined || turn < 0 || turn > 3) continue;
507
+ const slot = state.shell.seats.findIndex((s) => s.id === ev.from);
508
+ const pl = slot >= 0 ? players[slot] : null;
509
+ if (pl && pl.a) pl.nd = turn;
510
+ }
511
+
512
+ // advance the slow sim only on step boundaries
513
+ if (ctx.tick % STEP_TICKS === 0) simStep(state, players, grid, ctx);
514
+
515
+ // timer horn: round elapsed → most territory wins → hand the shell `over`
516
+ if (ctx.tick - state.shell.phaseSince >= ROUND_TICKS) {
517
+ state.win = mostTerritory(players, grid);
518
+ state.shell.over = true;
519
+ }
520
+ }
521
+
522
+ // ── the shell closes the round (runs LAST) ──
523
+ if (closeRound(state.shell, ctx, SHELL_OPTS) === "reset") clearGame(state, players, grid);
524
+
525
+ // re-pack the working structures into the compact wire form
526
+ state.g = encodeGrid(grid);
527
+ writePlayers(state, players);
528
+ return state;
529
+ },
530
+ } satisfies Room<State, Cmd>;
@@ -0,0 +1,116 @@
1
+ # The authoritative tick — how server truth works
2
+
3
+ Read this before changing any server logic in `room.ts`. It is the core Silt model; every
4
+ other skill assumes it.
5
+
6
+ ## The contract
7
+
8
+ `room.ts` default-exports an object with one required method (and an optional `init`):
9
+
10
+ ```ts
11
+ export default {
12
+ init() { return freshState(); }, // runs once when the room is created
13
+ tick(state = freshState(), inputs, ctx) { // 60×/sec while ≥1 peer is present
14
+ /* mutate state, return it */ return state;
15
+ },
16
+ } satisfies Room<State, Cmd>;
17
+ ```
18
+
19
+ - Runs **server-side**, **60 times per second**, while ≥1 peer is present (an empty room pauses).
20
+ - Receives a **fresh clone of the canonical state** each tick. Mutate it freely and return it.
21
+ **Never stash a reference across ticks** — next tick gets a new clone; a stashed reference is
22
+ a determinism bug waiting to happen.
23
+ - Returns the new state, which the server **broadcasts in full to every client**.
24
+
25
+ ## Intent in, truth out
26
+
27
+ Clients cannot write state. They call `send(...)` (see `skills/genre-lane-mapping.md`), which
28
+ arrives in `tick` as an entry in the `inputs` batch. Your `tick` is the ONLY place truth is
29
+ decided. The pattern — **validate intent, then commit**. From this template, a turn:
30
+
31
+ ```ts
32
+ const turn = (ev.data as Partial<Cmd>)?.turn;
33
+ if (turn === undefined || turn < 0 || turn > 3) continue; // reject: not a valid direction
34
+ if (pl && pl.a) pl.nd = turn; // accept: buffered, applied next step
35
+ ```
36
+
37
+ And the sim itself refuses an illegal move — you can't reverse into your own neck:
38
+
39
+ ```ts
40
+ if (pl.nd !== opposite(pl.d)) pl.d = pl.nd; // a 180° reversal is silently ignored
41
+ ```
42
+
43
+ A malicious client can *ask* to teleport or reverse; the server decides what actually happens.
44
+ This is the whole security model — never trust intent, always decide in `tick`.
45
+
46
+ ## The input batch
47
+
48
+ `inputs` is an ordered `Input<Cmd>[]`. Each entry is one of:
49
+
50
+ | `kind` | fields | meaning |
51
+ |---|---|---|
52
+ | `"join"` | `id` | a peer joined |
53
+ | `"leave"` | `id`, `reason: "left" \| "timeout"` | a peer left (clean bye vs dropped) |
54
+ | `"input"` | `from`, `data: Cmd` | a peer's latest **datagram** intent (droppable, latest-wins) |
55
+ | `"event"` | `from`, `data: unknown` | a peer's **reliable** event (ordered) — here, `ready` + `turn` |
56
+
57
+ Ordering within a tick is deterministic: joins, then leaves (by id), then reliable events
58
+ (arrival order), then each present peer's single latest `input`. Membership rides the same batch
59
+ — there are no join/leave callbacks; you handle them by looping `inputs`. This genre reads only
60
+ `event`s (ready-up handled by the shell, turns by the genre) — it doesn't use the datagram
61
+ `input` lane at all (see `skills/genre-lane-mapping.md` for why).
62
+
63
+ ## The determinism realm — the rule that bites first
64
+
65
+ `tick` runs in a sandbox where **non-deterministic APIs are removed**. A determinism doctor
66
+ replays your contract on every hot-reload and **rejects** it if two runs of the same inputs
67
+ diverge. Inside `tick` you must NOT use:
68
+
69
+ - `Date.now()`, `performance.now()`, `new Date()` — use `ctx.time` / `ctx.tick`.
70
+ - `Math.random()`, `crypto.getRandomValues` — use `ctx.random()`.
71
+ - `fetch`, timers, or any I/O.
72
+
73
+ The `ctx` gives deterministic substitutes:
74
+
75
+ ```ts
76
+ ctx.tick // integer tick count from 0 — THE clock. Count ticks to measure time.
77
+ ctx.dt // fixed 1/60. Never wall time.
78
+ ctx.time // ctx.tick * ctx.dt, seconds. Derived.
79
+ ctx.random() // deterministic hash(seed, tick, drawIndex) → [0,1). Reproducible.
80
+ ctx.emit(ev) // queue a reliable event to broadcast AFTER this tick (from "@server").
81
+ ```
82
+
83
+ This template uses `ctx.random()` to place spawns and `ctx.tick` for the round timer and
84
+ respawn countdowns. If the doctor rejects a change, you reached for a forbidden API — find it
85
+ and route through `ctx`.
86
+
87
+ ## Slow sim inside a fast clock
88
+
89
+ `tick` runs at 60Hz, but a grid game wants ~6 steps/sec. So the sim only ADVANCES every
90
+ `STEP_TICKS` ticks; the ticks in between just absorb input and return state unchanged:
91
+
92
+ ```ts
93
+ if (ctx.tick % STEP_TICKS === 0) simStep(state, players, grid, ctx);
94
+ ```
95
+
96
+ Turns buffer as they arrive (any tick) and are consumed on the next step. This is a common Silt
97
+ pattern — a cheap way to get a slower discrete cadence on the fixed 60Hz loop.
98
+
99
+ ## Failure is safe
100
+
101
+ Throwing inside `tick` **skips that tick** — the last good state holds, the room survives. One
102
+ bad input can't kill the room. Still, prefer explicit validation over relying on throws.
103
+
104
+ ## Verify
105
+
106
+ `tick` is a pure function of `(state, inputs, ctx)`, so test it with no browser: build inputs,
107
+ call `tick`, assert the returned state. See `room.test.ts` — membership, phase transitions,
108
+ claim/collision/respawn, and the datagram budget are all asserted by scripted ticks.
109
+
110
+ Two test styles live there, and you pick by what you're proving:
111
+ - **Drive the game forward** — use the `makeRunner()` helper (`step(inputs)` ticks the sim one
112
+ step at a time from an empty room). Best for lifecycle and "play it and see" assertions.
113
+ - **Pin an exact board** — construct a `State` literal (grid + `writePlayers([...])`) and call
114
+ `contract.tick(state, inputs, makeCtx(...))` once. Best when a precise pre-seeded position
115
+ matters (the collision test does this) — you control the whole board instead of playing into
116
+ it. `makeCtx(tick, seed)` gives a deterministic `Ctx`.