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,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
+ <meta name="apple-mobile-web-app-capable" content="yes" />
7
+ <meta name="mobile-web-app-capable" content="yes" />
8
+ <style>html, body { overscroll-behavior: none; }</style>
9
+ <title>turn-based grid — agent substrate</title>
10
+ </head>
11
+ <body style="margin: 0; background: #0b0b0c">
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "turn-based-grid-game",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently -k -n room,web -c yellow,cyan \"siltrun dev room.ts\" \"vite\"",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@siltrun/stage": "^0.1.0",
14
+ "pixi.js": "^8.19.0",
15
+ "@siltrun/client": "^0.3.0",
16
+ "@siltrun/react": "^0.1.0",
17
+ "react": "^18.3.0",
18
+ "react-dom": "^18.3.0"
19
+ },
20
+ "devDependencies": {
21
+ "@siltrun/room": "^0.1.0",
22
+ "@types/react": "^18.3.0",
23
+ "@types/react-dom": "^18.3.0",
24
+ "@vitejs/plugin-react": "^4.3.0",
25
+ "concurrently": "^9.0.0",
26
+ "siltrun": "^0.1.0",
27
+ "typescript": "^5.6.0",
28
+ "vite": "^5.4.0"
29
+ }
30
+ }
@@ -0,0 +1,309 @@
1
+ // room.ts — THE AUTHORITY. This contract runs server-side inside Silt's deterministic
2
+ // realm; it is the single source of truth the whole world projects from. Agents submit
3
+ // intent on the reliable lane; this tick() decides what actually happens; every peer
4
+ // (playing agents + spectating humans) receives the resulting State each tick.
5
+ //
6
+ // THE TICK / BEAT MODEL (DIG-725, the load-bearing decision) ─────────────────────────
7
+ //
8
+ // • The relay clock is a constant 60Hz and never pauses while ≥1 peer is present.
9
+ // We do NOT fight it. On top of it we quantize turns into BEATS of BEAT_TICKS ticks.
10
+ // • Agents buffer at most ONE action per beat (reliable lane, latest-wins within the
11
+ // window — you may revise until the beat lands). We hold the buffer in `state.pending`.
12
+ // • At the beat boundary, ALL buffered actions resolve SIMULTANEOUSLY from one snapshot,
13
+ // with symmetric conflict rules (two movers into one cell → both bounce; no initiative
14
+ // order to explain). Validation is at RESOLVE time, so agents act on ~1-beat-stale info.
15
+ // • An agent still thinking when the boundary lands has nothing buffered → it idles that
16
+ // beat (`missedBeats`++). The too-slow failure is visible and is the fun.
17
+ // • Emotes are EXEMPT from beat-buffering: they apply on their arrival tick (still
18
+ // deterministic — ordered inputs) and decay after EMOTE_DECAY_BEATS. This keeps a 4s
19
+ // beat alive for spectators without touching turn fairness.
20
+ //
21
+ // Determinism: this file touches only ctx.tick and ctx.random. No Date, no Math.random,
22
+ // no wall clock — the realm would throw on ambient authority inside tick() anyway.
23
+
24
+ import type { Room, Ctx } from "@siltrun/room";
25
+ import type { State, Cmd, Dir, Player, Pos } from "./src/substrate/types.ts";
26
+ import {
27
+ BEAT_TICKS,
28
+ ROUND_BEATS,
29
+ INTERMISSION_BEATS,
30
+ EMOTE_DECAY_BEATS,
31
+ PELLET_COUNT,
32
+ FLOOR_CELLS,
33
+ SPAWNS,
34
+ isFloor,
35
+ step,
36
+ cellKey,
37
+ samePos,
38
+ glyphFor,
39
+ isEmoteGlyph,
40
+ } from "./src/substrate/map.ts";
41
+
42
+ // ── initial (empty, waiting) state ──
43
+ function freshState(): State {
44
+ return {
45
+ phase: "waiting",
46
+ round: 0,
47
+ clock: 0,
48
+ beat: 0,
49
+ phaseStartTick: 0,
50
+ nextBeatTick: BEAT_TICKS,
51
+ players: {},
52
+ spectators: [],
53
+ pending: {},
54
+ pellets: [],
55
+ seatCounter: 0,
56
+ };
57
+ }
58
+
59
+ // ── deterministic pellet spawn: pick PELLET_COUNT distinct floor cells not under a player ──
60
+ function spawnPellets(state: State, ctx: Ctx): Pos[] {
61
+ const occupied = new Set<string>();
62
+ for (const id in state.players) occupied.add(cellKey(state.players[id].pos));
63
+ const candidates = FLOOR_CELLS.filter((c) => !occupied.has(cellKey(c)));
64
+ // Fisher–Yates with ctx.random (deterministic: hash(seed, tick, draw)); take the first N.
65
+ const pool = candidates.slice();
66
+ for (let i = pool.length - 1; i > 0; i--) {
67
+ const j = Math.floor(ctx.random() * (i + 1));
68
+ const t = pool[i];
69
+ pool[i] = pool[j];
70
+ pool[j] = t;
71
+ }
72
+ return pool.slice(0, Math.min(PELLET_COUNT, pool.length));
73
+ }
74
+
75
+ // ── seat a spectator as a player, assigning a spawn point + stable glyph ──
76
+ function seat(state: State, id: string): void {
77
+ if (state.players[id]) return; // already seated
78
+ const seatIndex = state.seatCounter++;
79
+ const spawn = SPAWNS[seatIndex % SPAWNS.length];
80
+ // If the natural spawn is taken, walk floor cells for the first free one (deterministic order).
81
+ let pos: Pos = spawn;
82
+ const taken = new Set<string>();
83
+ for (const pid in state.players) taken.add(cellKey(state.players[pid].pos));
84
+ if (taken.has(cellKey(pos))) {
85
+ pos = FLOOR_CELLS.find((c) => !taken.has(cellKey(c))) ?? spawn;
86
+ }
87
+ const player: Player = {
88
+ id,
89
+ glyph: glyphFor(seatIndex),
90
+ pos: { x: pos.x, y: pos.y },
91
+ score: 0,
92
+ emote: null,
93
+ emoteUntilBeat: 0,
94
+ missedBeats: 0,
95
+ actedLastBeat: false,
96
+ };
97
+ state.players[id] = player;
98
+ state.spectators = state.spectators.filter((s) => s !== id);
99
+ }
100
+
101
+ function unseat(state: State, id: string): void {
102
+ delete state.players[id];
103
+ delete state.pending[id];
104
+ }
105
+
106
+ // ── start a fresh round: place seated players at spawns, spawn pellets, reset the beat span ──
107
+ function startRound(state: State, ctx: Ctx): void {
108
+ state.round += 1;
109
+ state.phase = "playing";
110
+ state.phaseStartTick = ctx.tick;
111
+ state.beat = 0;
112
+ state.nextBeatTick = ctx.tick + BEAT_TICKS;
113
+ // Re-home every seated player to a spawn and clear round-scoped counters.
114
+ let i = 0;
115
+ for (const id in state.players) {
116
+ const p = state.players[id];
117
+ const spawn = SPAWNS[i % SPAWNS.length];
118
+ p.pos = { x: spawn.x, y: spawn.y };
119
+ p.score = 0;
120
+ p.missedBeats = 0;
121
+ p.actedLastBeat = false;
122
+ p.emote = null;
123
+ p.emoteUntilBeat = 0;
124
+ i++;
125
+ }
126
+ state.pending = {};
127
+ state.pellets = spawnPellets(state, ctx);
128
+ }
129
+
130
+ function enterIntermission(state: State, ctx: Ctx): void {
131
+ state.phase = "intermission";
132
+ state.phaseStartTick = ctx.tick;
133
+ state.beat = 0;
134
+ state.nextBeatTick = ctx.tick + BEAT_TICKS;
135
+ state.pending = {};
136
+ }
137
+
138
+ // ── the simultaneous, symmetric beat resolution ──
139
+ //
140
+ // Everyone resolves from the SAME pre-move snapshot. A player moves iff its target is
141
+ // floor AND not the current cell of a player who is staying AND not targeted by any
142
+ // other mover. Any contention → bounce (stay). No initiative, order-independent, fair.
143
+ function resolveBeat(state: State): void {
144
+ const ids = Object.keys(state.players);
145
+ const before: Record<string, Pos> = {};
146
+ const intended: Record<string, Pos> = {};
147
+ for (const id of ids) {
148
+ const p = state.players[id];
149
+ before[id] = { x: p.pos.x, y: p.pos.y };
150
+ }
151
+
152
+ // 1. intended target for each seated player (buffered move, or stay).
153
+ for (const id of ids) {
154
+ const move: Dir = state.pending[id] ?? "stay";
155
+ const target = move === "stay" ? before[id] : step(before[id], move);
156
+ // wall / out-of-bounds → bounce to origin.
157
+ intended[id] = isFloor(target.x, target.y) ? target : before[id];
158
+ }
159
+
160
+ // 2. cells occupied by a player who is NOT moving this beat (stayers block).
161
+ const stayerCells = new Set<string>();
162
+ for (const id of ids) {
163
+ if (samePos(intended[id], before[id])) stayerCells.add(cellKey(before[id]));
164
+ }
165
+
166
+ // 3. count how many movers target each destination cell (to detect head-on contention).
167
+ const targetCount = new Map<string, number>();
168
+ for (const id of ids) {
169
+ if (samePos(intended[id], before[id])) continue; // stayers don't contend
170
+ const k = cellKey(intended[id]);
171
+ targetCount.set(k, (targetCount.get(k) ?? 0) + 1);
172
+ }
173
+
174
+ // 4. grant a move only if its target is uncontended by other movers AND not a stayer's cell.
175
+ const granted: Record<string, boolean> = {};
176
+ for (const id of ids) {
177
+ if (samePos(intended[id], before[id])) { granted[id] = false; continue; }
178
+ const k = cellKey(intended[id]);
179
+ const contested = (targetCount.get(k) ?? 0) > 1 || stayerCells.has(k);
180
+ granted[id] = !contested;
181
+ }
182
+
183
+ // 5. apply, mark acted/missed, collect pellets.
184
+ for (const id of ids) {
185
+ const p = state.players[id];
186
+ const buffered = state.pending[id] != null;
187
+ if (buffered && p.missedBeats >= 0) p.actedLastBeat = true;
188
+ if (!buffered) { p.missedBeats += 1; p.actedLastBeat = false; }
189
+
190
+ if (granted[id]) p.pos = { x: intended[id].x, y: intended[id].y };
191
+
192
+ // pellet pickup on the resulting cell.
193
+ const here = cellKey(p.pos);
194
+ const idx = state.pellets.findIndex((pel) => cellKey(pel) === here);
195
+ if (idx !== -1) {
196
+ state.pellets.splice(idx, 1);
197
+ p.score += 1;
198
+ }
199
+ }
200
+
201
+ state.pending = {};
202
+ }
203
+
204
+ function decayEmotes(state: State): void {
205
+ for (const id in state.players) {
206
+ const p = state.players[id];
207
+ if (p.emote && state.beat >= p.emoteUntilBeat) p.emote = null;
208
+ }
209
+ }
210
+
211
+ // ── the contract ──
212
+ export default {
213
+ tick(state = freshState(), inputs, ctx): State {
214
+ state.clock = ctx.tick;
215
+
216
+ // 1) fold in everything that happened since last tick (deterministic order:
217
+ // joins → leaves → reliable events → datagram inputs; we use joins/leaves/events).
218
+ for (const ev of inputs) {
219
+ if (ev.kind === "join") {
220
+ if (!state.players[ev.id] && !state.spectators.includes(ev.id)) {
221
+ state.spectators.push(ev.id);
222
+ }
223
+ continue;
224
+ }
225
+ if (ev.kind === "leave") {
226
+ unseat(state, ev.id);
227
+ state.spectators = state.spectators.filter((s) => s !== ev.id);
228
+ continue;
229
+ }
230
+ if (ev.kind === "event") {
231
+ const msg = ev.data as Cmd;
232
+ if (!msg || typeof msg !== "object") continue;
233
+ switch (msg.t) {
234
+ case "sit":
235
+ seat(state, ev.from);
236
+ break;
237
+ case "stand":
238
+ unseat(state, ev.from);
239
+ if (!state.spectators.includes(ev.from)) state.spectators.push(ev.from);
240
+ break;
241
+ case "act":
242
+ // buffer latest-wins; only seated players can act, only during play.
243
+ if (state.players[ev.from] && state.phase === "playing") {
244
+ const m = msg.move;
245
+ if (m === "up" || m === "down" || m === "left" || m === "right" || m === "stay") {
246
+ state.pending[ev.from] = m;
247
+ }
248
+ }
249
+ break;
250
+ case "emote":
251
+ // applies immediately (exempt from beat-buffering), decays after N beats.
252
+ if (state.players[ev.from] && isEmoteGlyph(msg.glyph)) {
253
+ const p = state.players[ev.from];
254
+ p.emote = msg.glyph;
255
+ p.emoteUntilBeat = state.beat + EMOTE_DECAY_BEATS;
256
+ }
257
+ break;
258
+ }
259
+ continue;
260
+ }
261
+ // ev.kind === "input" (datagram lane) is unused by this genre — turns ride reliable.
262
+ }
263
+
264
+ const seatedCount = Object.keys(state.players).length;
265
+
266
+ // 2) lifecycle: waiting → playing → intermission → (playing | waiting), agent-population-based.
267
+ if (state.phase === "waiting") {
268
+ if (seatedCount > 0) startRound(state, ctx);
269
+ state.nextBeatTick = state.phaseStartTick + BEAT_TICKS; // keep view honest while waiting
270
+ return state;
271
+ }
272
+
273
+ // 3) beat boundary crossing. `beat` advances whenever the clock passes nextBeatTick.
274
+ // A pause/resume across an empty room can't desync: everything is anchored to
275
+ // phaseStartTick, which we reset on every phase entry.
276
+ if (ctx.tick >= state.nextBeatTick) {
277
+ const elapsedBeats = Math.floor((ctx.tick - state.phaseStartTick) / BEAT_TICKS);
278
+
279
+ if (state.phase === "playing") {
280
+ resolveBeat(state);
281
+ state.beat = elapsedBeats;
282
+ decayEmotes(state);
283
+ state.nextBeatTick = state.phaseStartTick + (elapsedBeats + 1) * BEAT_TICKS;
284
+ // round over? (all pellets gone also ends it early)
285
+ if (state.beat >= ROUND_BEATS || state.pellets.length === 0) {
286
+ enterIntermission(state, ctx);
287
+ }
288
+ } else {
289
+ // intermission: just advance the beat counter; roll to next round or back to waiting.
290
+ state.beat = elapsedBeats;
291
+ state.nextBeatTick = state.phaseStartTick + (elapsedBeats + 1) * BEAT_TICKS;
292
+ if (state.beat >= INTERMISSION_BEATS) {
293
+ if (Object.keys(state.players).length > 0) startRound(state, ctx);
294
+ else {
295
+ const s = freshState();
296
+ s.clock = ctx.tick;
297
+ s.round = state.round;
298
+ s.spectators = state.spectators;
299
+ s.phaseStartTick = ctx.tick;
300
+ s.nextBeatTick = ctx.tick + BEAT_TICKS;
301
+ return s;
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ return state;
308
+ },
309
+ } satisfies Room<State, Cmd>;
@@ -0,0 +1,97 @@
1
+ # Skill: play the turn-based grid
2
+
3
+ A hands-on recipe for turning the briefing in [`../AGENTS.md`](../AGENTS.md) into a
4
+ **working brain** — an agent that perceives the ASCII grid, decides a move, and acts before
5
+ the beat deadline. Agent-agnostic: any agent runtime that can (a) receive the rendered ASCII
6
+ and (b) send a reliable-lane JSON message can use this.
7
+
8
+ Read `AGENTS.md` first for the world model, the tick/beat model, the action API, and the
9
+ symbol/emote tables. This file is the *how*.
10
+
11
+ ---
12
+
13
+ ## The loop
14
+
15
+ ```
16
+ connect
17
+ send { "t": "sit" } # once — become a player
18
+ loop forever:
19
+ ascii = <latest rendered perception> # your only input
20
+ deadline = ticks-until-beat (from the status line)
21
+ move = decide(ascii) # keep this FAST — you have a deadline
22
+ send { "t": "act", "move": move } # reliable lane; latest-wins within the beat
23
+ ```
24
+
25
+ Two rules that matter more than cleverness:
26
+
27
+ 1. **Beat the deadline.** The `beat resolves in: N ticks` line is a countdown. If `decide`
28
+ takes longer than that, you buffer nothing and miss the beat (your piece idles, your
29
+ `missed` count climbs). A rough move on time beats a perfect move that's late.
30
+ 2. **You may revise.** Sent a move, then realized a better one before the beat landed? Send
31
+ again — latest-wins. Cheap insurance: send an early rough move, refine if time allows.
32
+
33
+ ---
34
+
35
+ ## Step 1 — parse the ASCII into something you can reason over
36
+
37
+ The perception is text. Turn it into structure:
38
+
39
+ - Split into the grid rows (the block of `#./*`-and-letters lines) and the status lines.
40
+ - Find **your own** glyph from the `you are: X` status line.
41
+ - Scan the grid: record wall cells (`#`), pellet cells (`*`), your position (your glyph),
42
+ and other players (other letters).
43
+
44
+ (A reference parser is in [`../src/view.ts`](../src/view.ts) — `readView(ascii)` returns
45
+ `{ self, pellets, others, isWall }`, all recovered from the string alone.)
46
+
47
+ ## Step 2 — decide a move
48
+
49
+ The simplest competent brain: **walk toward the nearest pellet.** Breadth-first search over
50
+ floor cells from your position; the first step of the shortest path to the closest pellet is
51
+ your move. If no pellet is reachable, take any legal step (or `stay`).
52
+
53
+ **Directions are grid-relative** (see AGENTS.md §1): `up` = row − 1 (toward the top), `down`
54
+ = row + 1, `left`/`right` = column ∓ 1. **Treat other players' cells as blocked when you
55
+ path** — but only for *this* beat: pieces move simultaneously, so a cell occupied now is
56
+ often free next beat. Re-plan each beat from fresh perception rather than committing to a
57
+ route around a piece that will have moved.
58
+
59
+ ```
60
+ decide(ascii):
61
+ v = parse(ascii)
62
+ if v.self is null: return "stay" # not seated / spectating
63
+ target = nearest reachable pellet (BFS over non-wall, non-occupied cells)
64
+ if target: return first-step-direction toward it
65
+ else: return any non-wall direction, or "stay"
66
+ ```
67
+
68
+ (Reference: `stepTowardNearestPellet(view)` in `../src/view.ts`, used by the `greedy` brain
69
+ in [`../src/brains.ts`](../src/brains.ts).)
70
+
71
+ ## Step 3 — (optional) use the emote channel
72
+
73
+ You have no chat, but you can flash one glyph (`! ? + - ^ x`) that others see for a couple
74
+ of beats. Send `{ "t": "emote", "glyph": "!" }` to signal. Emotes don't consume your turn —
75
+ they apply immediately. Convention gives the glyphs meaning (see `AGENTS.md` §4); the
76
+ substrate just delivers, shows, and decays them.
77
+
78
+ ---
79
+
80
+ ## What "a good brain" looks like
81
+
82
+ - **Never misses on a clear board.** If a pellet is reachable and you have time, you should
83
+ be moving toward it every beat. A climbing `missed` count means your `decide` is too slow
84
+ or you're not sending in time.
85
+ - **Handles contention.** When another piece contests your target cell, both bounce. Notice
86
+ repeated bounces and try a different route rather than shoving into the same wall.
87
+ - **Degrades gracefully.** No reachable pellet, or ambiguous perception? Return `stay` — a
88
+ deliberate, legal non-move — never nothing.
89
+
90
+ ## The bar this template sets
91
+
92
+ Four scripted brains ship as proof the substrate is playable from the ASCII alone —
93
+ `greedy` (this recipe), `random`, `chatty` (adds emotes), and `slow` (deliberately misses
94
+ its deadline, to show the stutter). Each perceives **only** the rendered ASCII, never the
95
+ server's state object. That's the whole claim: if a brain can play from the text, the
96
+ agent-facing projection is a sufficient interface — which is what a real agent connection
97
+ (over MCP) will target.
@@ -0,0 +1,141 @@
1
+ // App.tsx — the SPECTATOR. Humans watch; they never take a seat (the demo variant is
2
+ // agent-play / human-spectate). This tab joins as a spectator, renders the live state,
3
+ // and — for local demoing — launches in-page stub agents named in the URL:
4
+ //
5
+ // http://localhost:5173/?agents=greedy,slow,chatty
6
+ //
7
+ // Each stub is its own real client connection (see stubAgent.ts). Ugly-e2e stage: the
8
+ // ASCII pane + scoreboard, driven by real agents. The graphics view lands in polish.
9
+
10
+ import { useEffect, useRef, useState } from "react";
11
+ import { useRoom } from "@siltrun/react";
12
+ import type { State } from "./substrate/types.ts";
13
+ import { renderAscii, SYMBOLS } from "./substrate/ascii.ts";
14
+ import { GraphicsView } from "./GraphicsView.tsx";
15
+ import { BRAINS } from "./brains.ts";
16
+ import { runStubAgent, type StubHandle } from "./stubAgent.ts";
17
+
18
+ // Defaults to the standard siltrun dev room-info port. Override for local multi-room
19
+ // setups with ?room=<port> or ?room=<full-url> (used by the monorepo verify loop).
20
+ const ROOM_URL = (() => {
21
+ const p = new URLSearchParams(location.search).get("room");
22
+ if (!p) return "http://localhost:4000";
23
+ return /^\d+$/.test(p) ? `http://localhost:${p}` : p;
24
+ })();
25
+ const SPECTATOR_ID = "spectator-" + Math.random().toString(36).slice(2, 8);
26
+
27
+ function useStubAgents() {
28
+ const started = useRef(false);
29
+ const [names, setNames] = useState<string[]>([]);
30
+ useEffect(() => {
31
+ if (started.current) return;
32
+ started.current = true;
33
+ const param = new URLSearchParams(location.search).get("agents");
34
+ if (!param) return;
35
+ const requested = param.split(",").map((s) => s.trim()).filter(Boolean);
36
+ const handles: Promise<StubHandle>[] = [];
37
+ const launched: string[] = [];
38
+ // A per-page-load nonce keeps stub ids unique across reloads — otherwise a fresh
39
+ // "greedy-0" collides with the previous load's still-closing "greedy-0" and the two
40
+ // same-id sessions thrash (the documented concurrent-same-id pathology). Real agents
41
+ // carry unique identities; the stubs must too.
42
+ const nonce = Math.random().toString(36).slice(2, 6);
43
+ requested.forEach((name, i) => {
44
+ const make = BRAINS[name];
45
+ if (!make) return;
46
+ const id = `${name}-${nonce}${i}`;
47
+ launched.push(name);
48
+ handles.push(runStubAgent({ url: ROOM_URL, id, brain: make() }));
49
+ });
50
+ setNames(launched);
51
+ return () => {
52
+ Promise.all(handles).then((hs) => hs.forEach((h) => h.stop()));
53
+ };
54
+ }, []);
55
+ return names;
56
+ }
57
+
58
+ export function App() {
59
+ const { state, status, error } = useRoom<State>(ROOM_URL, { id: SPECTATOR_ID });
60
+ const agents = useStubAgents();
61
+ const [showAscii, setShowAscii] = useState(true);
62
+
63
+ if (status === "failed") {
64
+ return <pre style={box}>connection failed: {String(error)} — is `npm run dev` running?</pre>;
65
+ }
66
+ if (!state) return <pre style={box}>{status === "reconnecting" ? "reconnecting…" : "joining…"}</pre>;
67
+
68
+ const players = Object.values(state.players).sort((a, b) => b.score - a.score);
69
+ const brainOf = (id: string) => id.split("-")[0];
70
+
71
+ return (
72
+ <div style={{ display: "flex", gap: 28, padding: 24, fontFamily: "ui-monospace, monospace", color: "#eae7de", background: "#0b0b0d", minHeight: "100vh", alignItems: "flex-start" }}>
73
+ {/* HUMAN VIEW — watch the agents play */}
74
+ <div>
75
+ <div style={{ color: "#7e837a", marginBottom: 8 }}>
76
+ human view — watch the agents play · phase <b style={{ color: "#cfc9bc" }}>{state.phase}</b> · round {state.round} · beat {state.beat}
77
+ </div>
78
+ <GraphicsView state={state} />
79
+ {agents.length > 0 && (
80
+ <div style={{ color: "#7e837a", marginTop: 10, fontSize: 13 }}>stub agents: {agents.join(", ")}</div>
81
+ )}
82
+ </div>
83
+
84
+ {/* AGENT VIEW — the exact ASCII an agent perceives (toggle) */}
85
+ {showAscii && (
86
+ <div>
87
+ <div style={{ color: "#7e837a", marginBottom: 8, display: "flex", gap: 10, alignItems: "center" }}>
88
+ agent view — the exact ASCII an agent reads
89
+ <button onClick={() => setShowAscii(false)} style={btn}>hide</button>
90
+ </div>
91
+ <pre style={{ ...box, fontSize: 18, lineHeight: 1.18 }}>{renderAscii(state)}</pre>
92
+ </div>
93
+ )}
94
+
95
+ <div style={{ minWidth: 280 }}>
96
+ {!showAscii && (
97
+ <button onClick={() => setShowAscii(true)} style={{ ...btn, marginBottom: 12 }}>show agent (ASCII) view</button>
98
+ )}
99
+ <div style={{ color: "#7e837a", marginBottom: 8 }}>scoreboard</div>
100
+ <table style={{ borderCollapse: "collapse", fontSize: 14, width: "100%" }}>
101
+ <thead>
102
+ <tr style={{ color: "#7e837a", textAlign: "left" }}>
103
+ <th style={cell}>#</th><th style={cell}>who</th><th style={cell}>score</th><th style={cell}>missed</th><th style={cell}>emote</th>
104
+ </tr>
105
+ </thead>
106
+ <tbody>
107
+ {players.map((p) => (
108
+ <tr key={p.id}>
109
+ <td style={cell}><b style={{ color: "#cfc9bc" }}>{p.glyph}</b></td>
110
+ <td style={cell}>{brainOf(p.id)}</td>
111
+ <td style={cell}>{p.score}</td>
112
+ <td style={{ ...cell, color: p.missedBeats > 0 ? "#a65a2e" : "#3a3a3d", fontWeight: p.missedBeats > 0 ? 700 : 400 }}>{p.missedBeats}</td>
113
+ <td style={{ ...cell, color: p.emote ? "#cfc9bc" : "#3a3a3d", fontSize: 16 }}>{p.emote ?? "·"}</td>
114
+ </tr>
115
+ ))}
116
+ {players.length === 0 && (
117
+ <tr><td style={cell} colSpan={5}>no agents seated — waiting…</td></tr>
118
+ )}
119
+ </tbody>
120
+ </table>
121
+
122
+ <div style={{ color: "#7e837a", margin: "20px 0 8px" }}>symbol table</div>
123
+ <table style={{ borderCollapse: "collapse", fontSize: 13 }}>
124
+ <tbody>
125
+ {SYMBOLS.map((s) => (
126
+ <tr key={s.glyph}><td style={{ ...cell, color: "#cfc9bc" }}>{s.glyph}</td><td style={cell}>{s.meaning}</td></tr>
127
+ ))}
128
+ </tbody>
129
+ </table>
130
+ <div style={{ color: "#7e837a", marginTop: 8, fontSize: 12 }}>watching: {state.spectators.length}</div>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const box: React.CSSProperties = { background: "#121214", padding: 16, borderRadius: 8, margin: 0 };
137
+ const cell: React.CSSProperties = { padding: "3px 10px 3px 0", borderBottom: "1px solid #26262a" };
138
+ const btn: React.CSSProperties = {
139
+ background: "#1c1c1f", color: "#cfc9bc", border: "1px solid #3a3a3d",
140
+ borderRadius: 6, padding: "3px 10px", fontSize: 12, cursor: "pointer", fontFamily: "ui-monospace, monospace",
141
+ };