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.
- package/index.mjs +98 -0
- package/package.json +18 -0
- package/templates/mass-grid/AGENTS.md +106 -0
- package/templates/mass-grid/README.md +59 -0
- package/templates/mass-grid/_gitignore +4 -0
- package/templates/mass-grid/index.html +22 -0
- package/templates/mass-grid/package.json +31 -0
- package/templates/mass-grid/room.test.ts +214 -0
- package/templates/mass-grid/room.ts +530 -0
- package/templates/mass-grid/skills/authoritative-tick.md +116 -0
- package/templates/mass-grid/skills/compact-snapshots.md +110 -0
- package/templates/mass-grid/skills/demo-room-lifecycle.md +145 -0
- package/templates/mass-grid/skills/genre-lane-mapping.md +88 -0
- package/templates/mass-grid/skills/territory-capture.md +115 -0
- package/templates/mass-grid/src/Game.tsx +174 -0
- package/templates/mass-grid/src/main.tsx +9 -0
- package/templates/mass-grid/tsconfig.json +15 -0
- package/templates/mass-grid/vite.config.ts +9 -0
- package/templates/minimal/README.md +39 -0
- package/templates/minimal/_gitignore +4 -0
- package/templates/minimal/index.html +15 -0
- package/templates/minimal/package.json +30 -0
- package/templates/minimal/room.ts +30 -0
- package/templates/minimal/src/Game.tsx +71 -0
- package/templates/minimal/src/main.tsx +9 -0
- package/templates/minimal/tsconfig.json +15 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tower-defense/AGENTS.md +132 -0
- package/templates/tower-defense/README.md +53 -0
- package/templates/tower-defense/_gitignore +4 -0
- package/templates/tower-defense/index.html +15 -0
- package/templates/tower-defense/package.json +31 -0
- package/templates/tower-defense/room.test.ts +159 -0
- package/templates/tower-defense/room.ts +321 -0
- package/templates/tower-defense/skills/authoritative-tick.md +96 -0
- package/templates/tower-defense/skills/demo-room-lifecycle.md +145 -0
- package/templates/tower-defense/skills/state-budget.md +79 -0
- package/templates/tower-defense/skills/two-lanes.md +66 -0
- package/templates/tower-defense/skills/waves-and-timing.md +79 -0
- package/templates/tower-defense/src/Game.tsx +227 -0
- package/templates/tower-defense/src/main.tsx +9 -0
- package/templates/tower-defense/tsconfig.json +15 -0
- package/templates/tower-defense/vite.config.ts +7 -0
- package/templates/turn-based-grid/AGENTS.md +183 -0
- package/templates/turn-based-grid/README.md +72 -0
- package/templates/turn-based-grid/_gitignore +4 -0
- package/templates/turn-based-grid/index.html +15 -0
- package/templates/turn-based-grid/package.json +30 -0
- package/templates/turn-based-grid/room.ts +309 -0
- package/templates/turn-based-grid/skills/play.md +97 -0
- package/templates/turn-based-grid/src/App.tsx +141 -0
- package/templates/turn-based-grid/src/GraphicsView.tsx +148 -0
- package/templates/turn-based-grid/src/brains.ts +112 -0
- package/templates/turn-based-grid/src/main.tsx +8 -0
- package/templates/turn-based-grid/src/stubAgent.ts +82 -0
- package/templates/turn-based-grid/src/substrate/ascii.ts +87 -0
- package/templates/turn-based-grid/src/substrate/map.ts +104 -0
- package/templates/turn-based-grid/src/substrate/types.ts +59 -0
- package/templates/turn-based-grid/src/view.ts +94 -0
- package/templates/turn-based-grid/tsconfig.json +15 -0
- package/templates/turn-based-grid/vite.config.ts +7 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// room.ts — co-op tower defense. This file runs SERVER-SIDE at 60Hz inside a
|
|
2
|
+
// deterministic realm: browsers submit intent, this tick() decides truth, and every
|
|
3
|
+
// client receives the full authoritative state each tick. Edit it while the dev loop
|
|
4
|
+
// is running — it hot-reloads (a determinism doctor re-checks each reload).
|
|
5
|
+
//
|
|
6
|
+
// Two things live here, deliberately separated:
|
|
7
|
+
// 1. THE DEMO-ROOM LIFECYCLE SHELL — genre-agnostic. One always-warm persistent room:
|
|
8
|
+
// drop-in join, take over an abandoned player or start fresh, ready-up → rolling
|
|
9
|
+
// rounds → play until you lose → a new game auto-starts. No lobby. Other genre
|
|
10
|
+
// templates COPY this block verbatim. Normative spec: skills/demo-room-lifecycle.md.
|
|
11
|
+
// 2. THE TOWER-DEFENSE GENRE — everything below the shell. Replace this to build a
|
|
12
|
+
// different game on the same lifecycle.
|
|
13
|
+
import type { Room, Input, Ctx } from "@siltrun/room";
|
|
14
|
+
|
|
15
|
+
// ════════════════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// ══ DEMO-ROOM LIFECYCLE SHELL — genre-agnostic. Copy verbatim. ══
|
|
17
|
+
// ══ Normative spec + invariants: skills/demo-room-lifecycle.md ══
|
|
18
|
+
// ════════════════════════════════════════════════════════════════════════════════════
|
|
19
|
+
|
|
20
|
+
export type Phase = "gathering" | "playing" | "gameover";
|
|
21
|
+
|
|
22
|
+
/** A fixed seat at the table. `id` is the peer holding it (null = empty). A seat whose
|
|
23
|
+
* peer left MID-GAME is kept (`abandoned`) so a newcomer can take it over — its game
|
|
24
|
+
* data (towers, etc.) keeps running. The original can also reclaim it by rejoining. */
|
|
25
|
+
export type Seat = { id: string | null; ready: boolean; abandoned: boolean };
|
|
26
|
+
|
|
27
|
+
/** Shell-owned meta. Namespaced under one key so the genre state stays cleanly separable
|
|
28
|
+
* and this whole block copies without collision. */
|
|
29
|
+
export type Shell = {
|
|
30
|
+
phase: Phase;
|
|
31
|
+
seats: Seat[];
|
|
32
|
+
round: number; // increments each game start
|
|
33
|
+
phaseSince: number; // ctx.tick when the current phase began — drives countdowns
|
|
34
|
+
over: boolean; // the GENRE sets this true to end a game (playing → gameover)
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ShellOpts = {
|
|
38
|
+
seats: number; // fixed table size
|
|
39
|
+
minPlayers: number; // ready seats required to start a game
|
|
40
|
+
gameoverTicks: number; // how long the gameover screen holds before auto-reset
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function initShell(o: ShellOpts): Shell {
|
|
44
|
+
return {
|
|
45
|
+
phase: "gathering",
|
|
46
|
+
seats: Array.from({ length: o.seats }, () => ({ id: null, ready: false, abandoned: false })),
|
|
47
|
+
round: 0,
|
|
48
|
+
phaseSince: 0,
|
|
49
|
+
over: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Seat a peer: resume your own seat (reconnect), else claim an empty seat, else TAKE
|
|
54
|
+
* OVER an abandoned seat (adopt its in-progress game data). No seat available →
|
|
55
|
+
* spectator (returns -1); a mid-game spectator waits for the next gathering. */
|
|
56
|
+
function seatFor(shell: Shell, id: string): number {
|
|
57
|
+
const own = shell.seats.findIndex((s) => s.id === id);
|
|
58
|
+
if (own >= 0) { shell.seats[own].abandoned = false; return own; } // reconnect
|
|
59
|
+
const empty = shell.seats.findIndex((s) => s.id === null);
|
|
60
|
+
if (empty >= 0) { shell.seats[empty] = { id, ready: false, abandoned: false }; return empty; }
|
|
61
|
+
const aband = shell.seats.findIndex((s) => s.abandoned);
|
|
62
|
+
if (aband >= 0) { shell.seats[aband].id = id; shell.seats[aband].abandoned = false; return aband; } // takeover
|
|
63
|
+
return -1; // spectator
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Runs FIRST in tick(). Applies membership (join/leave) + `ready` events, then opens a
|
|
67
|
+
* round when enough seats are ready. Returns "start" on the tick a game begins (the genre
|
|
68
|
+
* seeds a fresh game on that signal); the caller passes back which seats went live. */
|
|
69
|
+
export function openRound(shell: Shell, inputs: Input[], ctx: Ctx, o: ShellOpts): "start" | null {
|
|
70
|
+
for (const ev of inputs) {
|
|
71
|
+
if (ev.kind === "join") {
|
|
72
|
+
seatFor(shell, ev.id);
|
|
73
|
+
} else if (ev.kind === "leave") {
|
|
74
|
+
const i = shell.seats.findIndex((s) => s.id === ev.id);
|
|
75
|
+
if (i < 0) continue;
|
|
76
|
+
if (shell.phase === "playing") shell.seats[i].abandoned = true; // keep data, allow takeover
|
|
77
|
+
else shell.seats[i] = { id: null, ready: false, abandoned: false }; // no game to preserve → free it
|
|
78
|
+
} else if (ev.kind === "event" && isReady(ev.data)) {
|
|
79
|
+
const i = shell.seats.findIndex((s) => s.id === ev.from);
|
|
80
|
+
if (i >= 0 && shell.phase === "gathering") shell.seats[i].ready = true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (shell.phase === "gathering") {
|
|
84
|
+
const readyCount = shell.seats.filter((s) => s.id && s.ready && !s.abandoned).length;
|
|
85
|
+
if (readyCount >= o.minPlayers) {
|
|
86
|
+
shell.phase = "playing";
|
|
87
|
+
shell.round += 1;
|
|
88
|
+
shell.phaseSince = ctx.tick;
|
|
89
|
+
shell.over = false;
|
|
90
|
+
for (const s of shell.seats) s.ready = false;
|
|
91
|
+
return "start";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Runs LAST in tick(). Advances playing→gameover (when the genre set `over`) and
|
|
98
|
+
* gameover→gathering (after the countdown, freeing abandoned seats). Returns "reset" on
|
|
99
|
+
* the tick a fresh gathering begins (the genre clears its game state on that signal). */
|
|
100
|
+
export function closeRound(shell: Shell, ctx: Ctx, o: ShellOpts): "reset" | null {
|
|
101
|
+
if (shell.phase === "playing" && shell.over) {
|
|
102
|
+
shell.phase = "gameover";
|
|
103
|
+
shell.phaseSince = ctx.tick;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (shell.phase === "gameover" && ctx.tick - shell.phaseSince >= o.gameoverTicks) {
|
|
107
|
+
shell.phase = "gathering";
|
|
108
|
+
shell.phaseSince = ctx.tick;
|
|
109
|
+
shell.over = false;
|
|
110
|
+
for (const s of shell.seats) {
|
|
111
|
+
if (s.abandoned) { s.id = null; s.abandoned = false; } // its player is gone
|
|
112
|
+
s.ready = false;
|
|
113
|
+
}
|
|
114
|
+
return "reset";
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isReady(data: unknown): boolean {
|
|
120
|
+
return !!data && typeof data === "object" && (data as { type?: unknown }).type === "ready";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ════════════════════════════════════════════════════════════════════════════════════
|
|
124
|
+
// ══ END DEMO-ROOM LIFECYCLE SHELL ══
|
|
125
|
+
// ════════════════════════════════════════════════════════════════════════════════════
|
|
126
|
+
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// TOWER-DEFENSE GENRE — replace everything below to build a different game.
|
|
129
|
+
//
|
|
130
|
+
// Design notes (each mechanic is here to TEACH a Silt substrate concept):
|
|
131
|
+
// • Waves spawn on a ctx.tick schedule with ctx.random jitter → the determinism realm
|
|
132
|
+
// • Creeps walk a fixed server-side path; clients interpolate → server-owns-truth
|
|
133
|
+
// • Fixed build slots; place via a reliable event, server valid.→ reliable lane + intent
|
|
134
|
+
// • Teammate cursors ride the datagram lane (kind:"input") → latest-wins presence
|
|
135
|
+
// • Shared gold + shared lives; a leak costs a life; 0 = loss → co-op shared state
|
|
136
|
+
//
|
|
137
|
+
// STATE BUDGET (see skills/state-budget.md): the whole State is broadcast every tick in
|
|
138
|
+
// ONE ~1200B datagram. Short keys on the arrays that scale (creeps/towers) are a
|
|
139
|
+
// deliberate budget choice — measured by room.test.ts. Positions are DERIVED client-side
|
|
140
|
+
// from (waypoint index + progress), never stored.
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
// The creeps' path as grid waypoints (0..1 arena space). Rendering derives pixel positions.
|
|
144
|
+
const PATH: ReadonlyArray<readonly [number, number]> = [
|
|
145
|
+
[0.0, 0.2], [0.8, 0.2], [0.8, 0.5], [0.2, 0.5], [0.2, 0.8], [1.0, 0.8],
|
|
146
|
+
];
|
|
147
|
+
// Fixed build slots (0..1 arena space) — the only places a tower may go.
|
|
148
|
+
const SLOTS: ReadonlyArray<readonly [number, number]> = [
|
|
149
|
+
[0.5, 0.12], [0.9, 0.35], [0.5, 0.42], [0.1, 0.42], [0.35, 0.62], [0.5, 0.9], [0.85, 0.65], [0.1, 0.72],
|
|
150
|
+
];
|
|
151
|
+
// Tower kinds: [cost, range(arena units), damage, cooldown(ticks)].
|
|
152
|
+
const KINDS: ReadonlyArray<readonly [number, number, number, number]> = [
|
|
153
|
+
[50, 0.18, 3, 30], // 0: arrow — cheap, steady
|
|
154
|
+
[90, 0.14, 8, 55], // 1: cannon — pricier, hits hard
|
|
155
|
+
];
|
|
156
|
+
const CAPS = { creeps: 14, towers: SLOTS.length }; // hard ceilings — keep state under MTU
|
|
157
|
+
const WAVES = 5;
|
|
158
|
+
const CREEP_SPEED = 0.006; // arena units / tick along the path
|
|
159
|
+
const START_GOLD = 120;
|
|
160
|
+
const START_LIVES = 20;
|
|
161
|
+
|
|
162
|
+
type Creep = { i: number; w: number; t: number; h: number }; // id, waypoint idx, seg progress 0..1, hp
|
|
163
|
+
type Tower = { s: number; k: number; c: number }; // slot idx, kind, cooldown-remaining
|
|
164
|
+
type Cursor = { x: number; y: number };
|
|
165
|
+
|
|
166
|
+
export type State = {
|
|
167
|
+
shell: Shell;
|
|
168
|
+
gold: number;
|
|
169
|
+
lives: number;
|
|
170
|
+
wave: number; // 0 = not started; 1..WAVES active
|
|
171
|
+
creeps: Creep[];
|
|
172
|
+
towers: Tower[];
|
|
173
|
+
next: number; // next creep id
|
|
174
|
+
spawnLeft: number; // creeps still to spawn this wave
|
|
175
|
+
spawnCd: number; // ticks until next spawn
|
|
176
|
+
cursors: Record<string, Cursor>; // teammate pointers — datagram lane, latest-wins
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The datagram-lane intent: a teammate's live cursor. */
|
|
180
|
+
export type Cmd = { x: number; y: number };
|
|
181
|
+
/** A reliable-lane event: place a tower, or ready-up. */
|
|
182
|
+
type Place = { type: "place"; slot: number; kind: number };
|
|
183
|
+
|
|
184
|
+
const SHELL_OPTS: ShellOpts = { seats: 2, minPlayers: 2, gameoverTicks: 180 };
|
|
185
|
+
|
|
186
|
+
function freshState(): State {
|
|
187
|
+
return {
|
|
188
|
+
shell: initShell(SHELL_OPTS),
|
|
189
|
+
gold: START_GOLD, lives: START_LIVES, wave: 0,
|
|
190
|
+
creeps: [], towers: [], next: 0, spawnLeft: 0, spawnCd: 0, cursors: {},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function startGame(state: State): void {
|
|
195
|
+
state.gold = START_GOLD;
|
|
196
|
+
state.lives = START_LIVES;
|
|
197
|
+
state.wave = 0;
|
|
198
|
+
state.creeps = [];
|
|
199
|
+
state.towers = [];
|
|
200
|
+
state.next = 0;
|
|
201
|
+
state.spawnLeft = 0;
|
|
202
|
+
state.spawnCd = 0;
|
|
203
|
+
beginWave(state, 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clearGame(state: State): void {
|
|
207
|
+
state.wave = 0;
|
|
208
|
+
state.creeps = [];
|
|
209
|
+
state.towers = [];
|
|
210
|
+
state.spawnLeft = 0;
|
|
211
|
+
state.spawnCd = 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function beginWave(state: State, wave: number): void {
|
|
215
|
+
state.wave = wave;
|
|
216
|
+
state.spawnLeft = 3 + wave * 2; // more creeps each wave
|
|
217
|
+
state.spawnCd = 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function dist(ax: number, ay: number, bx: number, by: number): number {
|
|
221
|
+
return Math.hypot(ax - bx, ay - by);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Derive a creep's arena position from (waypoint idx + progress). Pure — clients run the
|
|
225
|
+
* same math to render, so positions never need to live in state. */
|
|
226
|
+
function creepPos(c: Creep): [number, number] {
|
|
227
|
+
const a = PATH[c.w], b = PATH[Math.min(c.w + 1, PATH.length - 1)];
|
|
228
|
+
return [a[0] + (b[0] - a[0]) * c.t, a[1] + (b[1] - a[1]) * c.t];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One tick of the tower-defense game. Only runs while shell.phase === "playing". */
|
|
232
|
+
function stepGame(state: State, inputs: Input<Cmd>[], ctx: Ctx): void {
|
|
233
|
+
// 1. reliable events: tower placement (server validates — occupied? affordable?)
|
|
234
|
+
for (const ev of inputs) {
|
|
235
|
+
if (ev.kind !== "event") continue;
|
|
236
|
+
const d = ev.data as Partial<Place>;
|
|
237
|
+
if (d?.type !== "place") continue;
|
|
238
|
+
const slot = d.slot ?? -1, kind = d.kind ?? -1;
|
|
239
|
+
if (slot < 0 || slot >= SLOTS.length || kind < 0 || kind >= KINDS.length) continue; // bad input
|
|
240
|
+
if (state.towers.some((t) => t.s === slot)) continue; // slot occupied
|
|
241
|
+
if (state.towers.length >= CAPS.towers) continue; // ceiling
|
|
242
|
+
const cost = KINDS[kind][0];
|
|
243
|
+
if (state.gold < cost) continue; // can't afford
|
|
244
|
+
state.gold -= cost;
|
|
245
|
+
state.towers.push({ s: slot, k: kind, c: 0 }); // truth: the server placed it
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 2. spawn this wave's creeps on a tick schedule with deterministic jitter
|
|
249
|
+
if (state.spawnLeft > 0) {
|
|
250
|
+
if (state.spawnCd <= 0) {
|
|
251
|
+
if (state.creeps.length < CAPS.creeps) {
|
|
252
|
+
const hp = 8 + state.wave * 4;
|
|
253
|
+
state.creeps.push({ i: state.next++, w: 0, t: 0, h: hp });
|
|
254
|
+
state.spawnLeft -= 1;
|
|
255
|
+
state.spawnCd = 24 + Math.floor(ctx.random() * 12); // jitter — ctx.random, never Math.random
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
state.spawnCd -= 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 3. towers fire (nearest creep in range)
|
|
263
|
+
for (const tw of state.towers) {
|
|
264
|
+
if (tw.c > 0) { tw.c -= 1; continue; }
|
|
265
|
+
const [tx, ty] = SLOTS[tw.s];
|
|
266
|
+
const [, range, dmg, cd] = KINDS[tw.k];
|
|
267
|
+
let best = -1, bestD = Infinity;
|
|
268
|
+
for (let j = 0; j < state.creeps.length; j++) {
|
|
269
|
+
const [cx, cy] = creepPos(state.creeps[j]);
|
|
270
|
+
const dd = dist(tx, ty, cx, cy);
|
|
271
|
+
if (dd <= range && dd < bestD) { bestD = dd; best = j; }
|
|
272
|
+
}
|
|
273
|
+
if (best >= 0) { state.creeps[best].h -= dmg; tw.c = cd; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 4. move creeps along the path; remove the dead (bounty) and leaks (a life)
|
|
277
|
+
const survivors: Creep[] = [];
|
|
278
|
+
for (const c of state.creeps) {
|
|
279
|
+
if (c.h <= 0) { state.gold += 5; continue; } // killed → bounty
|
|
280
|
+
c.t += CREEP_SPEED / segLen(c.w);
|
|
281
|
+
while (c.t >= 1 && c.w < PATH.length - 1) { c.t -= 1; c.w += 1; }
|
|
282
|
+
if (c.w >= PATH.length - 1 && c.t >= 1) { state.lives -= 1; continue; } // leaked the exit
|
|
283
|
+
survivors.push(c);
|
|
284
|
+
}
|
|
285
|
+
state.creeps = survivors;
|
|
286
|
+
|
|
287
|
+
// 5. wave / loss progression
|
|
288
|
+
if (state.spawnLeft === 0 && state.creeps.length === 0) {
|
|
289
|
+
if (state.wave < WAVES) beginWave(state, state.wave + 1);
|
|
290
|
+
else state.shell.over = true; // cleared all waves — the demo ends in a win, then resets
|
|
291
|
+
}
|
|
292
|
+
if (state.lives <= 0) state.shell.over = true; // loss → the shell rolls a new game
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function segLen(w: number): number {
|
|
296
|
+
const a = PATH[w], b = PATH[Math.min(w + 1, PATH.length - 1)];
|
|
297
|
+
return Math.max(1e-3, Math.hypot(b[0] - a[0], b[1] - a[1]));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default {
|
|
301
|
+
tick(state = freshState(), inputs, ctx) {
|
|
302
|
+
// datagram lane: teammate cursors (latest-wins). Only keep cursors for live seats.
|
|
303
|
+
for (const ev of inputs) {
|
|
304
|
+
if (ev.kind === "input") state.cursors[ev.from] = { x: ev.data.x, y: ev.data.y };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── the lifecycle shell drives the room ──
|
|
308
|
+
if (openRound(state.shell, inputs, ctx, SHELL_OPTS) === "start") startGame(state);
|
|
309
|
+
if (state.shell.phase === "playing") stepGame(state, inputs, ctx);
|
|
310
|
+
if (closeRound(state.shell, ctx, SHELL_OPTS) === "reset") clearGame(state);
|
|
311
|
+
|
|
312
|
+
// prune cursors for peers no longer seated (keeps state small + correct)
|
|
313
|
+
const live = new Set(state.shell.seats.map((s) => s.id).filter(Boolean));
|
|
314
|
+
for (const id in state.cursors) if (!live.has(id)) delete state.cursors[id];
|
|
315
|
+
|
|
316
|
+
return state;
|
|
317
|
+
},
|
|
318
|
+
} satisfies Room<State, Cmd>;
|
|
319
|
+
|
|
320
|
+
// Exposed for the derive/verify tooling (room.test.ts) — not part of the wire contract.
|
|
321
|
+
export { PATH, SLOTS, KINDS, CAPS, WAVES, freshState, creepPos };
|
|
@@ -0,0 +1,96 @@
|
|
|
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:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
export default {
|
|
12
|
+
tick(state, inputs, ctx) { /* mutate state, return it */ return state; },
|
|
13
|
+
} satisfies Room<State, Cmd>;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- Runs **server-side**, **60 times per second**, while ≥1 peer is present (an empty room
|
|
17
|
+
pauses — see `skills/waves-and-timing.md`).
|
|
18
|
+
- Receives a **fresh clone of the canonical state** each tick. Mutate it freely and return it.
|
|
19
|
+
**Never stash a reference across ticks** — next tick gets a new clone; a stashed reference is
|
|
20
|
+
a determinism bug waiting to happen.
|
|
21
|
+
- Returns the new state, which the server **broadcasts in full to every client**.
|
|
22
|
+
- Optional `init()` runs once when the room is created and returns the starting state. This
|
|
23
|
+
template uses a default parameter instead (`tick(state = freshState(), …)`) — either works.
|
|
24
|
+
If neither seeds it, the first tick receives `state === undefined`.
|
|
25
|
+
|
|
26
|
+
## Intent in, truth out
|
|
27
|
+
|
|
28
|
+
Clients cannot write state. They call `send(...)` (see `skills/two-lanes.md`), which arrives in
|
|
29
|
+
`tick` as an entry in the `inputs` batch. Your `tick` is the ONLY place truth is decided.
|
|
30
|
+
|
|
31
|
+
The pattern to internalize — **validate intent, then commit**. From this template's `stepGame`,
|
|
32
|
+
tower placement:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
if (state.towers.some((t) => t.s === slot)) continue; // reject: slot occupied
|
|
36
|
+
if (state.gold < cost) continue; // reject: can't afford
|
|
37
|
+
state.gold -= cost; // commit: now it's truth
|
|
38
|
+
state.towers.push({ s: slot, k: kind, c: 0 });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A malicious client can *ask* to place ten free towers; the server prices and validates every
|
|
42
|
+
one. This is the whole security model — never trust intent, always decide in `tick`.
|
|
43
|
+
|
|
44
|
+
## The input batch
|
|
45
|
+
|
|
46
|
+
`inputs` is an ordered `Input<Cmd>[]`. Each entry is one of:
|
|
47
|
+
|
|
48
|
+
| `kind` | fields | meaning |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `"join"` | `id` | a peer joined |
|
|
51
|
+
| `"leave"` | `id`, `reason: "left" \| "timeout"` | a peer left (clean bye vs dropped) |
|
|
52
|
+
| `"input"` | `from`, `data: Cmd` | a peer's latest **datagram** intent (droppable, latest-wins) — here, a cursor |
|
|
53
|
+
| `"event"` | `from`, `data: unknown` | a peer's **reliable** event (ordered) — here, `ready`/`place` |
|
|
54
|
+
|
|
55
|
+
Ordering within a tick is deterministic: joins, then leaves (by id), then reliable events
|
|
56
|
+
(arrival order), then each present peer's single latest `input`. Membership rides the same
|
|
57
|
+
batch — there are no join/leave callbacks; you handle them by looping `inputs`. This template
|
|
58
|
+
loops it twice: the shell reads join/leave/`ready`; `stepGame` reads `place`; the top of `tick`
|
|
59
|
+
reads cursor `input`s. That's fine — iterate as many times as clarity wants.
|
|
60
|
+
|
|
61
|
+
## The determinism realm — the rule that bites first
|
|
62
|
+
|
|
63
|
+
`tick` runs in a sandbox where **non-deterministic APIs are removed**. A determinism doctor
|
|
64
|
+
replays your contract on every hot-reload and **rejects** it if two runs of the same inputs
|
|
65
|
+
diverge. Inside `tick` you must NOT use:
|
|
66
|
+
|
|
67
|
+
- `Date.now()`, `performance.now()`, `new Date()` — use `ctx.time` / `ctx.tick`.
|
|
68
|
+
- `Math.random()`, `crypto.getRandomValues` — use `ctx.random()`.
|
|
69
|
+
- `fetch`, timers (`setTimeout`/`setInterval`), or any I/O.
|
|
70
|
+
|
|
71
|
+
Why: the server must be able to replay ticks identically (crash recovery, verification). The
|
|
72
|
+
`ctx` gives you deterministic substitutes:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
ctx.tick // integer tick count from 0 — THE clock. Count ticks to measure time.
|
|
76
|
+
ctx.dt // fixed 1/60. Never wall time.
|
|
77
|
+
ctx.time // ctx.tick * ctx.dt, seconds. Derived.
|
|
78
|
+
ctx.random() // deterministic hash(seed, tick, drawIndex) → [0,1). Reproducible.
|
|
79
|
+
ctx.emit(ev) // queue a reliable event to broadcast AFTER this tick (from "@server").
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This template uses `ctx.random()` for spawn jitter and `ctx.tick` for phase countdowns. If the
|
|
83
|
+
doctor rejects a change, you reached for a forbidden API — find it and route through `ctx`.
|
|
84
|
+
|
|
85
|
+
## Failure is safe
|
|
86
|
+
|
|
87
|
+
Throwing inside `tick` **skips that tick** — the last good state holds, the room survives. One
|
|
88
|
+
bad input can't kill the room. Still, prefer explicit validation (like the placement checks
|
|
89
|
+
above) over relying on throws.
|
|
90
|
+
|
|
91
|
+
## Verify
|
|
92
|
+
|
|
93
|
+
`tick` is a pure function of `(state, inputs, ctx)`, so test it with no browser: build inputs,
|
|
94
|
+
call `tick`, assert the returned state. See `room.test.ts` — including a determinism test that
|
|
95
|
+
runs the same script twice and asserts identical output. That test is how you *prove* your
|
|
96
|
+
change stayed deterministic, beyond the doctor's reload check.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# The demo-room lifecycle shell
|
|
2
|
+
|
|
3
|
+
**This is the always-warm, tap-and-play room model — genre-agnostic.** It is the block
|
|
4
|
+
between the `══ DEMO-ROOM LIFECYCLE SHELL ══` markers in `room.ts`. Copy that block
|
|
5
|
+
verbatim into any genre template; wire your game to the three signals it emits. This
|
|
6
|
+
document is its **normative spec**: the state, the transitions, and the invariants an
|
|
7
|
+
extender must preserve.
|
|
8
|
+
|
|
9
|
+
## The idea
|
|
10
|
+
|
|
11
|
+
One constant, persistent room. No lobby, no matchmaking, no "create game" button. A player
|
|
12
|
+
opens the page and is immediately *in* — they drop into a seat, ready up, and play. When a
|
|
13
|
+
game ends, a new one starts on its own. The room is always warm because the Silt room runs
|
|
14
|
+
whenever ≥1 peer is present and pauses when empty (BOUNDARIES §6).
|
|
15
|
+
|
|
16
|
+
Three moves define it:
|
|
17
|
+
|
|
18
|
+
- **Drop-in join** — claim an empty seat the instant you arrive.
|
|
19
|
+
- **Take over an abandoned player, or start fresh** — if a teammate left mid-game, the next
|
|
20
|
+
arrival inherits their in-progress position; the game never pauses for a missing player.
|
|
21
|
+
- **Ready-up → rolling rounds → play until you lose → a new game starts** — the loop runs
|
|
22
|
+
forever with no operator.
|
|
23
|
+
|
|
24
|
+
## State
|
|
25
|
+
|
|
26
|
+
The shell owns one namespaced key, `shell`, so your genre state stays cleanly separable:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
type Phase = "gathering" | "playing" | "gameover";
|
|
30
|
+
type Seat = { id: string | null; ready: boolean; abandoned: boolean };
|
|
31
|
+
type Shell = {
|
|
32
|
+
phase: Phase;
|
|
33
|
+
seats: Seat[]; // fixed length = opts.seats; index is the stable seat number
|
|
34
|
+
round: number; // increments each time a game starts
|
|
35
|
+
phaseSince: number; // ctx.tick when the current phase began — drives countdowns
|
|
36
|
+
over: boolean; // YOUR GENRE sets this true to end a game (see "The over flag")
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A **seat** is a fixed slot at the table, addressed by index (`seats[0]`, `seats[1]`, …).
|
|
41
|
+
`id` is the peer holding it, or `null` if empty. `abandoned` marks a seat whose peer left
|
|
42
|
+
*mid-game*: the seat and its game data are kept so a newcomer can take it over.
|
|
43
|
+
|
|
44
|
+
## Phases
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
48
|
+
▼ │
|
|
49
|
+
gathering ──(≥ minPlayers ready)──▶ playing ──(over)──▶ gameover ──┘
|
|
50
|
+
(after gameoverTicks)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **gathering** — seated players ready up. When `minPlayers` seats are occupied *and* ready,
|
|
54
|
+
a game starts.
|
|
55
|
+
- **playing** — the genre runs. Rolling rounds/waves happen entirely inside your game code;
|
|
56
|
+
the shell does not know or care what a "round" is. It only waits for you to set `over`.
|
|
57
|
+
- **gameover** — a brief hold (`gameoverTicks`) so players see the result, then the room
|
|
58
|
+
auto-resets to `gathering` and the loop repeats.
|
|
59
|
+
|
|
60
|
+
## Seat assignment — the takeover rule
|
|
61
|
+
|
|
62
|
+
On a `join`, the shell seats the peer by this priority (`seatFor` in the shell):
|
|
63
|
+
|
|
64
|
+
1. **Reconnect** — if the peer already holds a seat (same `id`), resume it and clear
|
|
65
|
+
`abandoned`. (Silt's stable-id reconnect means a dropped player who returns keeps its
|
|
66
|
+
seat — see WIRE "Identity & reconnect".)
|
|
67
|
+
2. **Empty seat** — claim the first `id === null` seat.
|
|
68
|
+
3. **Takeover** — else adopt the first `abandoned` seat: set its `id` to the newcomer, clear
|
|
69
|
+
`abandoned`. The seat's genre data (towers, score, …) carries over untouched.
|
|
70
|
+
4. **Spectator** — if every seat is held by a live player, the joiner gets no seat
|
|
71
|
+
(`seatFor` returns −1). A mid-game spectator waits; the next `gathering` frees seats and
|
|
72
|
+
they can ready up then.
|
|
73
|
+
|
|
74
|
+
On a `leave`:
|
|
75
|
+
|
|
76
|
+
- **During `playing`** → mark the seat `abandoned` (keep `id` and all game data). The game
|
|
77
|
+
continues; the seat is now takeover-eligible.
|
|
78
|
+
- **During `gathering`/`gameover`** → free the seat entirely (`id: null`), because there is
|
|
79
|
+
no in-progress game to preserve.
|
|
80
|
+
|
|
81
|
+
On reset (`gameover → gathering`), any still-`abandoned` seat is freed — its player is gone.
|
|
82
|
+
|
|
83
|
+
## The three signals — how your genre wires in
|
|
84
|
+
|
|
85
|
+
The shell exposes exactly two functions and one flag. Your `tick` composes them around your
|
|
86
|
+
game step:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
tick(state = freshState(), inputs, ctx) {
|
|
90
|
+
// (optional) datagram-lane intent you handle yourself, e.g. cursors
|
|
91
|
+
|
|
92
|
+
if (openRound(state.shell, inputs, ctx, SHELL_OPTS) === "start") startGame(state); // ① seed a fresh game
|
|
93
|
+
if (state.shell.phase === "playing") stepGame(state, inputs, ctx); // ② run your game; set shell.over on loss/win
|
|
94
|
+
if (closeRound(state.shell, ctx, SHELL_OPTS) === "reset") clearGame(state); // ③ tear the game down
|
|
95
|
+
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **`openRound(...) === "start"`** — fired on the single tick a game begins. Seed a fresh
|
|
101
|
+
game: reset resources, spawn the first wave, clear the board. Runs FIRST so membership and
|
|
102
|
+
the start transition are settled before your game steps.
|
|
103
|
+
- **`shell.over`** — YOUR game sets this `true` when the game ends (you lost, you won, time
|
|
104
|
+
ran out — the shell doesn't judge). It is the ONLY bridge from genre → shell. `closeRound`
|
|
105
|
+
reads it.
|
|
106
|
+
- **`closeRound(...) === "reset"`** — fired on the single tick a fresh `gathering` begins
|
|
107
|
+
(after the gameover hold). Clear your game state back to idle. Runs LAST.
|
|
108
|
+
|
|
109
|
+
**Order is load-bearing:** `openRound` before your step (so a just-started game steps this
|
|
110
|
+
same tick), `closeRound` after (so an `over` set during the step is seen this same tick).
|
|
111
|
+
|
|
112
|
+
## `SHELL_OPTS`
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const SHELL_OPTS = { seats: 2, minPlayers: 2, gameoverTicks: 180 };
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `seats` — table size (fixed). This template ships 2 (the smallest co-op).
|
|
119
|
+
- `minPlayers` — ready seats required to start. Keep ≤ `seats`.
|
|
120
|
+
- `gameoverTicks` — how long `gameover` holds before reset. 180 ticks = 3s at 60Hz.
|
|
121
|
+
|
|
122
|
+
## Invariants an extender MUST preserve
|
|
123
|
+
|
|
124
|
+
Break these and the room stops being a safe, always-warm demo:
|
|
125
|
+
|
|
126
|
+
1. **`shell` is the only shell-owned state.** Keep it namespaced; don't scatter phase/seat
|
|
127
|
+
fields into your genre state.
|
|
128
|
+
2. **Only the genre writes `shell.over`; only the shell writes `shell.phase`.** One flag in,
|
|
129
|
+
one machine out. Don't set `phase` from game code.
|
|
130
|
+
3. **Seats are fixed-length and index-stable.** Never `push`/`splice` `seats`; mutate slots
|
|
131
|
+
in place. Clients and game data address players by seat index.
|
|
132
|
+
4. **A mid-game leave abandons, never deletes.** Deleting a seat's data mid-game desyncs any
|
|
133
|
+
client rendering it and forfeits the takeover promise.
|
|
134
|
+
5. **State stays under the datagram MTU (~1100B).** The shell is ~150B of that budget; the
|
|
135
|
+
rest is your genre's. See `state-budget.md`. Fixed seats + short keys keep it bounded.
|
|
136
|
+
6. **`tick` stays deterministic.** No wall clock / `Math.random` / `fetch` inside the shell
|
|
137
|
+
or your step — use `ctx` (BOUNDARIES §5). `phaseSince` uses `ctx.tick`, never time.
|
|
138
|
+
|
|
139
|
+
## Verifying the shell
|
|
140
|
+
|
|
141
|
+
The shell is a pure function of `(state, inputs, ctx)`, so it is tested without a browser —
|
|
142
|
+
see `room.test.ts`: scripted input sequences assert seat claim, takeover of an abandoned
|
|
143
|
+
seat, reconnect-resume, the ready-up gate, `gathering → playing → gameover → gathering`, and
|
|
144
|
+
a loss rolling into a fresh game. Run `bun test room.test.ts`. When you change the shell, the
|
|
145
|
+
membership and phase-transition tests are your safety net — keep them green.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# The state budget — the ceiling that shapes everything
|
|
2
|
+
|
|
3
|
+
Read this before adding anything to `State`. It is the one hard constraint that most shapes how
|
|
4
|
+
you design a Silt game, and the reason tower defense — a genre that wants many entities on
|
|
5
|
+
screen — is a good stress test of the substrate.
|
|
6
|
+
|
|
7
|
+
## The ceiling
|
|
8
|
+
|
|
9
|
+
The server broadcasts the **entire authoritative state** to every client **every tick** (60Hz),
|
|
10
|
+
as a single WebTransport **datagram**. There is no diffing, no delta compression, no interest
|
|
11
|
+
management — the whole `State`, serialized to JSON, every tick.
|
|
12
|
+
|
|
13
|
+
A QUIC datagram gives you roughly **~1200 bytes of usable payload**. At ~40 bytes of JSON per
|
|
14
|
+
small entity, that's a hard ceiling of about **28–30 small entities** in state before the
|
|
15
|
+
datagram no longer fits. **A state that outgrows the datagram is silently undeliverable on that
|
|
16
|
+
lane** — there is no automatic fallback. So the budget is not a soft guideline; exceed it and
|
|
17
|
+
the game breaks quietly.
|
|
18
|
+
|
|
19
|
+
(Full limits doc: the silt repo's `docs/BOUNDARIES.md` §1.)
|
|
20
|
+
|
|
21
|
+
## The three disciplines
|
|
22
|
+
|
|
23
|
+
**1. Store only what the server must own to decide truth.** Positions, health, ownership, whose
|
|
24
|
+
turn — the minimum. Everything else is derived.
|
|
25
|
+
|
|
26
|
+
**2. Derive rendering client-side — don't store it.** This is the biggest saver. This template
|
|
27
|
+
never stores creep pixel positions. It stores a creep as `{ i, w, t, h }` — id, **waypoint
|
|
28
|
+
index**, **progress along that segment** (0..1), and hp — and both server and client compute the
|
|
29
|
+
actual position from the shared `PATH` via `creepPos()`. Animation, interpolation, particle
|
|
30
|
+
effects, glow, health-bar layout — all live in `Game.tsx`, none in `State`. A creep costs ~4
|
|
31
|
+
numbers on the wire instead of a fat object.
|
|
32
|
+
|
|
33
|
+
**3. Short keys on the arrays that scale.** Object keys are literal bytes in the JSON, repeated
|
|
34
|
+
per entity per tick. The types that appear in bounded-but-growing arrays use terse keys:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
type Creep = { i: number; w: number; t: number; h: number }; // NOT {id,waypoint,progress,hp}
|
|
38
|
+
type Tower = { s: number; k: number; c: number }; // slot, kind, cooldown
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Meta fields that appear **once** (`gold`, `lives`, `wave`) stay readable — one occurrence, the
|
|
42
|
+
bytes don't multiply. Only shorten what repeats.
|
|
43
|
+
|
|
44
|
+
## Hard caps
|
|
45
|
+
|
|
46
|
+
The template caps the arrays that could otherwise blow the budget:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const CAPS = { creeps: 14, towers: SLOTS.length /* 8 */ };
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Spawns refuse to exceed `CAPS.creeps`; placement refuses past the fixed slots. With 2 seats + 8
|
|
53
|
+
towers + 14 creeps + meta + 2 cursors, the **worst-case serialized state is ~931 bytes** —
|
|
54
|
+
comfortably under the ~1100B budget. When you add fields or raise caps, you spend against that
|
|
55
|
+
headroom.
|
|
56
|
+
|
|
57
|
+
## The budget is a test, not a hope
|
|
58
|
+
|
|
59
|
+
`room.test.ts` builds a **maxed** state (full seats, every slot towered, creep array saturated,
|
|
60
|
+
cursors set) and asserts:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
expect(Buffer.byteLength(JSON.stringify(s), "utf8")).toBeLessThan(1100);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**When you add anything to `State`, this test is your guardrail.** Add a field to `Creep`, raise
|
|
67
|
+
`CAPS.creeps`, add a third seat — re-run `bun test room.test.ts` and watch the printed
|
|
68
|
+
`worst-case state = NNNB`. If it approaches 1100, you must claw bytes back: shorten a key, drop a
|
|
69
|
+
stored value you can derive, lower a cap. Don't ship a state you haven't measured at its worst.
|
|
70
|
+
|
|
71
|
+
## If you genuinely need more than the budget allows
|
|
72
|
+
|
|
73
|
+
- **Hidden information** (fog of war, secret hands) can't be solved by "just don't render it" —
|
|
74
|
+
every client receives the full state and can read the wire. Handle secrets client-side (e.g.
|
|
75
|
+
commit-reveal). See `docs/BOUNDARIES.md` §2.
|
|
76
|
+
- **More entities than fit** — you're past what alpha Silt broadcasts. Options: reduce entity
|
|
77
|
+
count (the design move), or represent many things as a few (e.g. a wave as a count + a
|
|
78
|
+
spawn-schedule rather than N individual pre-spawned creeps). Per-tick diffs + keyframes are the
|
|
79
|
+
designed v1 path but are **not built** — don't design assuming them.
|