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,9 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ // The Vite app runs on :5173 and discovers the room at http://localhost:4000
5
+ // (siltrun dev's room-info endpoint) at runtime — no proxy, no cert handling here.
6
+ export default defineConfig({
7
+ plugins: [react()],
8
+ server: { port: 5173 },
9
+ });
@@ -0,0 +1,39 @@
1
+ # my silt game
2
+
3
+ A multiplayer room on [Silt](https://silt.run) — an authoritative server contract
4
+ (`room.ts`) plus a React client (`src/`), one project, one language.
5
+
6
+ ## Run
7
+
8
+ ```bash
9
+ npm install
10
+ npm run dev
11
+ ```
12
+
13
+ This boots both halves:
14
+
15
+ - **room** — `siltrun dev room.ts`: your contract running authoritatively at 60Hz
16
+ (room-info on `http://localhost:4000`, WebTransport on `:4433`)
17
+ - **web** — vite dev server on [http://localhost:5173](http://localhost:5173)
18
+
19
+ Open `http://localhost:5173` in **two windows** — that's your multiplayer room.
20
+
21
+ ## Edit
22
+
23
+ - `room.ts` — the server's truth: membership, movement, rules. Hot-reloads on save
24
+ (a determinism doctor re-checks each reload).
25
+ - `src/Game.tsx` — the client: `useRoom(url, { id })` gives you `{ state, send }`.
26
+ `send(data)` is droppable intent (movement); `send(data, { reliable: true })` is
27
+ an ordered event (chat, turns). Rendering is [@siltrun/stage](https://silt.run/docs)
28
+ (PixiJS): `createStage` + one camera call + one input primitive is the whole
29
+ ceremony — touch, viewport fit, and DPR handling come free, so the game is
30
+ phone-playable from the first run.
31
+
32
+ The two share types through `import type { State } from "../room"` — erased at
33
+ build, so no server code reaches the browser.
34
+
35
+ ## Learn more
36
+
37
+ - Quickstart & contract rules: https://silt.run/docs
38
+ - The lanes (datagram vs reliable), determinism realm, and limits are the load-bearing
39
+ docs — read them before designing a game.
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .DS_Store
@@ -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>my silt game</title>
10
+ </head>
11
+ <body>
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": "my-silt-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,30 @@
1
+ // room.ts — your authoritative room contract. This file runs SERVER-SIDE at 60Hz;
2
+ // browsers submit intent, this tick() decides truth, and every client receives the
3
+ // full state each tick. Edit it while `npm run dev` is running — it hot-reloads.
4
+ import type { Room } from "@siltrun/room";
5
+
6
+ type Ship = { x: number; y: number; tx?: number; ty?: number };
7
+ export type State = { ships: Record<string, Ship> };
8
+ export type Cmd = { x: number; y: number };
9
+
10
+ const clamp = (v: number) => Math.max(0, Math.min(520, v));
11
+
12
+ export default {
13
+ tick(state = { ships: {} }, inputs) {
14
+ for (const ev of inputs) {
15
+ if (ev.kind === "leave") { delete state.ships[ev.id]; continue; }
16
+ if (ev.kind === "join") { state.ships[ev.id] = { x: 260, y: 140 }; continue; }
17
+ if (ev.kind === "input") {
18
+ const s = state.ships[ev.from];
19
+ if (s) { s.tx = clamp(ev.data.x); s.ty = clamp(ev.data.y); } // the server clamps intent
20
+ }
21
+ }
22
+ for (const id in state.ships) {
23
+ const s = state.ships[id];
24
+ if (s.tx == null || s.ty == null) continue;
25
+ s.x += (s.tx - s.x) * 0.1;
26
+ s.y += (s.ty - s.y) * 0.1;
27
+ }
28
+ return state;
29
+ },
30
+ } satisfies Room<State, Cmd>;
@@ -0,0 +1,71 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useRoom } from "@siltrun/react";
3
+ import { createStage, createCamera, type StageHandle } from "@siltrun/stage";
4
+ import { tapBoard } from "@siltrun/stage/input";
5
+ import { Graphics } from "pixi.js";
6
+ // The server and client share types with a type-only import — erased at build, so
7
+ // nothing server-side leaks into the bundle. One project, one language, one seam.
8
+ import type { State } from "../room";
9
+
10
+ // A stable player identity, kept for this browser tab.
11
+ const PLAYER_ID = sessionStorage.getItem("player-id") ?? crypto.randomUUID();
12
+ sessionStorage.setItem("player-id", PLAYER_ID);
13
+
14
+ // The playfield in world units — the same 0..520 space the server clamps intent to.
15
+ const FIELD = { x: 0, y: 0, w: 520, h: 280 };
16
+ const INK = 0x0b0b0d, BONE = 0xeae7de, SILT = 0x7e837a, HAIR = 0x3a3a3d;
17
+
18
+ export function Game() {
19
+ const { state, send, status, error } = useRoom<State>("http://localhost:4000", { id: PLAYER_ID });
20
+
21
+ // React owns the DOM chrome; the stage redraws the world each frame from this ref.
22
+ const stateRef = useRef(state);
23
+ stateRef.current = state;
24
+ const hostRef = useRef<HTMLDivElement>(null);
25
+
26
+ useEffect(() => {
27
+ let stage: StageHandle | undefined;
28
+ let cancelled = false;
29
+ // createStage is awaited INSIDE the effect — never top-level. (A top-level await
30
+ // deadlocks a bundled build; see the @siltrun/stage README.)
31
+ createStage(hostRef.current!, { background: INK }).then((s) => {
32
+ if (cancelled) return s.dispose();
33
+ stage = s;
34
+
35
+ // One camera call: frame the field on any screen, refit on rotate/resize.
36
+ const cam = createCamera(s);
37
+ const fit = () => cam.fitRect(FIELD, { pad: 24 });
38
+ fit(); s.onResize(fit);
39
+
40
+ // One input primitive: tap (or click) → world point → intent to the server.
41
+ tapBoard(s.app.canvas, {
42
+ map: (sx, sy) => cam.toWorld(sx, sy),
43
+ onTap: (p) => send({ x: p.x, y: p.y }),
44
+ });
45
+
46
+ // Draw: vector shapes redrawn from authoritative state every frame.
47
+ const gfx = s.world.addChild(new Graphics());
48
+ s.app.ticker.add((tk) => {
49
+ cam.update(tk.deltaMS / 1000);
50
+ gfx.clear();
51
+ gfx.rect(FIELD.x, FIELD.y, FIELD.w, FIELD.h).stroke({ color: HAIR, width: 2 });
52
+ for (const [id, ship] of Object.entries(stateRef.current?.ships ?? {})) {
53
+ gfx.circle(ship.x, ship.y, 8).fill(id === PLAYER_ID ? BONE : SILT);
54
+ }
55
+ });
56
+ });
57
+ return () => { cancelled = true; stage?.dispose(); }; // dispose tears down ticker + canvas + inputs
58
+ }, [send]);
59
+
60
+ const hint = status === "failed"
61
+ ? `connection failed: ${String(error)} — is \`npm run dev\` running?`
62
+ : !state ? (status === "reconnecting" ? "reconnecting…" : "joining…")
63
+ : "tap the field to send your ship — open a second window for player two";
64
+
65
+ return (
66
+ <main style={{ position: "fixed", inset: 0, fontFamily: "system-ui" }}>
67
+ <div ref={hostRef} style={{ position: "absolute", inset: 0 }} />
68
+ <p style={{ position: "absolute", left: 0, right: 0, bottom: 8, margin: 0, textAlign: "center", pointerEvents: "none", color: "#7e837a", fontSize: 12 }}>{hint}</p>
69
+ </main>
70
+ );
71
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { Game } from "./Game.tsx";
4
+
5
+ createRoot(document.getElementById("root")!).render(
6
+ <React.StrictMode>
7
+ <Game />
8
+ </React.StrictMode>,
9
+ );
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "skipLibCheck": true,
12
+ "noEmit": true
13
+ },
14
+ "include": ["src", "room.ts", "vite.config.ts"]
15
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: { port: 5173 },
7
+ });
@@ -0,0 +1,132 @@
1
+ # AGENTS.md — co-op tower defense on Silt
2
+
3
+ You (a coding agent) are working in a **Silt** project: an authoritative multiplayer room.
4
+ This file orients you so you can extend the game immediately. Read it fully once, then read
5
+ the one skill in `skills/` that matches what you're about to change. The skills are the deep
6
+ knowledge; this file is the map.
7
+
8
+ ## What this project is
9
+
10
+ A **co-op tower defense** starter. Two players share one always-warm room: they drop in,
11
+ ready up, and defend a path against waves of creeps together — shared gold, shared lives.
12
+ It is a **teaching template**, not a finished game: minimal genre mechanics chosen so each
13
+ one demonstrates a Silt concept. Build your real game by extending it.
14
+
15
+ Two parts, deliberately separated in `room.ts`:
16
+
17
+ 1. **The demo-room lifecycle shell** — the `══ DEMO-ROOM LIFECYCLE SHELL ══` block. Genre-
18
+ agnostic: the always-warm, tap-and-play room model (drop-in join, take over an abandoned
19
+ teammate, ready-up → rolling rounds → lose → auto-restart, no lobby). You usually **don't
20
+ edit this** — you wire your game to its three signals. Spec: `skills/demo-room-lifecycle.md`.
21
+ 2. **The tower-defense genre** — everything below the shell. This is what you replace or
22
+ extend to change the game.
23
+
24
+ ## The one mental model that matters
25
+
26
+ **The server owns truth. Clients submit intent. The room decides.**
27
+
28
+ ```
29
+ browser ──send(intent)──▶ Go relay ──inputs──▶ room.ts tick() ──full state──▶ every browser
30
+ (WebTransport) (Bun, 60Hz, deterministic)
31
+ ```
32
+
33
+ `room.ts` default-exports `{ tick }`. It runs **server-side at 60Hz** inside a deterministic
34
+ realm. Each tick it receives the canonical state + a batch of inputs (joins, leaves, and the
35
+ peers' intent), mutates state, and returns it. The server broadcasts the **entire** returned
36
+ state to every client every tick. Clients never own state — they render what the server sends
37
+ and send intent back. This is why cheating is hard: the client asking to place a tower is
38
+ *intent*; `tick` decides whether it's allowed (occupied? affordable?) and only then is it truth.
39
+
40
+ Full model → `skills/authoritative-tick.md`.
41
+
42
+ ## Code map
43
+
44
+ | File | What it is |
45
+ |---|---|
46
+ | `room.ts` | The authoritative contract. Shell block (copy-verbatim) + TD genre. **The game lives here.** |
47
+ | `room.test.ts` | Deterministic contract tests — `tick` is a pure function, so you test it with no browser. Your safety net. |
48
+ | `src/Game.tsx` | The React client. `useRoom(url, { id })` → `{ state, send }`. Renders on `@siltrun/stage`: `createStage` + `cam.fitRect(arena)` + `tapBoard` (tap a slot to place — phone-first). Derives rendering (creep positions, etc.) from state. |
49
+ | `src/main.tsx`, `index.html` | Standard vite entry. |
50
+ | `skills/` | The deep concept docs. Read the one for your change. |
51
+
52
+ ## Run it + verify your change
53
+
54
+ ```bash
55
+ npm install
56
+ npm run dev # boots BOTH halves: `siltrun dev room.ts` (room-info :4000) + vite (:5173)
57
+ ```
58
+
59
+ Open `http://localhost:5173` in **two windows** — that's your two-player room. Ready up in both
60
+ to start.
61
+
62
+ **Verify by observation, not by reading your own diff:**
63
+ - `bun test room.test.ts` — the fast loop, and your **primary** verification. The contract is a
64
+ pure function; scripted inputs assert truth (membership, phase transitions, placement rules,
65
+ the state-size budget). It runs with **no install** because the `@siltrun/*` imports are type-only
66
+ and erased at runtime. When you change mechanics, add/adjust a test and keep it green.
67
+ - Note: `npm run typecheck` (`tsc`) will NOT work until the `@siltrun/*` packages are published
68
+ (see the alpha limitation below) — it errors on unresolved modules, not on your code. Don't
69
+ chase those errors; `bun test` is the real check in the shipped state.
70
+ - Two windows for the live feel. `room.ts` hot-reloads on save (a determinism doctor re-checks
71
+ each reload) — if it rejects your change as non-deterministic, read `skills/authoritative-tick.md`.
72
+
73
+ ## ⚠️ Alpha limitation — installing `@siltrun/*`
74
+
75
+ At the time this template ships, the `@siltrun/*` packages are **not yet published to npm**, so
76
+ `npm install` here will fail to resolve them. Until the publish lands, consume Silt from a
77
+ local checkout using the workspace path documented in the silt repo's `docs/QUICKSTART.md`
78
+ (clone silt, point your project's `package.json` `workspaces`/deps at `vendor/silt/packages/*`,
79
+ `bun install`, `bun run dev`). The game code in this template is identical either way — only the
80
+ dependency wiring differs. Once the packages are published, the `package.json` here works as-is.
81
+
82
+ ## Extension recipes — the common moves
83
+
84
+ Each is small because the design is small on purpose. Read the cited skill first.
85
+
86
+ - **Add a *damage* tower kind** (`skills/state-budget.md`) — append `[cost, range, damage,
87
+ cooldown]` to `KINDS` in `room.ts`, add a picker button in `Game.tsx`'s `KindPicker` and a
88
+ glyph branch in the slots draw (the `t.k` switch). The `place` event already carries
89
+ `kind`; the server already validates cost/slot. That's the whole change — but only because the
90
+ effect is *damage*, which the existing tower loop already applies.
91
+ - **Add a tower whose effect ISN'T damage** (slow, poison, gold-bonus, chain…) — the one-liner
92
+ above is NOT enough; that recipe assumes damage. A non-damage effect also means:
93
+ 1. a **new branch in the tower loop** in `stepGame` — the stock loop finds the single nearest
94
+ creep and damages it. Decide your targeting (nearest-one vs **area/aura over all creeps in
95
+ range**) and write it; there's no default for "affect everyone in range".
96
+ 2. usually a **new field on `Creep`** to carry the effect (a slow-timer, a poison stack). This
97
+ spends against the **state budget** — keep the key one char and update the saturated-creep
98
+ push in `room.test.ts`'s budget test so the guard stays honest. Read `skills/state-budget.md`.
99
+ 3. often a **movement/behavior modifier** elsewhere in `stepGame` that reads that field.
100
+ 4. The `KINDS` tuple is `[cost, range, damage, cooldown]`. If your effect's parameters don't
101
+ fit (slow factor, duration), the clean move is module constants (`const SLOW_FACTOR = 0.5`),
102
+ not widening the tuple — leave `damage` at 0 for a pure-effect tower.
103
+ 5. To render the *status* (not just position/hp), you must put the effect field in `State` so
104
+ the client can read it — a real byte cost you're choosing to pay for the visual. Weigh it
105
+ against the budget.
106
+ - **Add a creep type** (`skills/waves-and-timing.md`, `skills/state-budget.md`) — add a field to
107
+ the `Creep` type (keep the key **short** — budget), vary it at spawn in `stepGame`, and branch
108
+ on it in the tower/movement loops. Render the variant in `Game.tsx`. Mind the byte budget test.
109
+ - **Change the path** — edit the `PATH` waypoint array in `room.ts`. Both the server (movement)
110
+ and the client (rendering) read the same array, so they stay in sync automatically.
111
+ - **Tune waves / difficulty** (`skills/waves-and-timing.md`) — `WAVES`, `beginWave`'s creep
112
+ count, `CREEP_SPEED`, `START_GOLD`/`START_LIVES`, hp/bounty in `stepGame`.
113
+ - **Change room rules** (`skills/demo-room-lifecycle.md`) — seats, `minPlayers`, gameover
114
+ duration live in `SHELL_OPTS`. If you edit the shell block itself, re-read its invariants.
115
+
116
+ ## Skills index — read the one that fits
117
+
118
+ | Skill | Read when you're… |
119
+ |---|---|
120
+ | `skills/authoritative-tick.md` | changing ANY server logic — the tick model, determinism realm, `ctx`, input batch |
121
+ | `skills/two-lanes.md` | sending data from the client — deciding datagram (presence) vs reliable (events) |
122
+ | `skills/waves-and-timing.md` | doing anything time-based — spawns, cooldowns, timers, delays (there is no `setTimeout`) |
123
+ | `skills/state-budget.md` | adding to `State` — the MTU ceiling that caps how much you can broadcast |
124
+ | `skills/demo-room-lifecycle.md` | touching room flow — seats, phases, ready-up, join/leave, or copying the shell |
125
+
126
+ ## The two hard rules you cannot violate
127
+
128
+ 1. **Determinism.** No `Date.now()`, `Math.random()`, `fetch`, or timers inside `tick`. Use the
129
+ injected `ctx` (`ctx.tick`, `ctx.random()`). The doctor enforces this on every reload.
130
+ 2. **State budget.** The whole `State` is broadcast in ONE ~1200-byte datagram every tick. Keep
131
+ it small (short keys, derive don't store). The `room.test.ts` budget test guards it. Details:
132
+ `skills/state-budget.md`.
@@ -0,0 +1,53 @@
1
+ # silt · co-op tower defense
2
+
3
+ A co-op tower defense game on [Silt](https://silt.run) — an authoritative server contract
4
+ (`room.ts`) plus a React client (`src/`), one project, one language. Two players share one
5
+ always-warm room: drop in, ready up, and defend a path against waves together.
6
+
7
+ **Building on this with a coding agent? Point it at [`AGENTS.md`](./AGENTS.md) first** — it's
8
+ the orientation map, and `skills/` holds the deep concept docs.
9
+
10
+ ## Run
11
+
12
+ ```bash
13
+ npm install
14
+ npm run dev
15
+ ```
16
+
17
+ Boots both halves:
18
+ - **room** — `siltrun dev room.ts`: your contract running authoritatively at 60Hz (room-info on
19
+ `http://localhost:4000`, WebTransport on `:4433`). Hot-reloads on save.
20
+ - **web** — vite dev server on [http://localhost:5173](http://localhost:5173).
21
+
22
+ Open `http://localhost:5173` in **two windows** and ready up in both — that's your game.
23
+
24
+ > **Alpha note:** the `@siltrun/*` packages aren't published to npm yet, so `npm install` won't
25
+ > resolve them until the publish lands. Until then, consume Silt from a local checkout per the
26
+ > silt repo's `docs/QUICKSTART.md` (the game code is identical; only dependency wiring differs).
27
+ > See [`AGENTS.md`](./AGENTS.md) → *Alpha limitation*.
28
+
29
+ ## Test
30
+
31
+ ```bash
32
+ bun test room.test.ts
33
+ ```
34
+
35
+ `room.ts` is a deterministic pure function, so the whole game is testable with no browser:
36
+ membership, phase transitions, tower-placement rules, and the state-size budget are all asserted
37
+ by scripted ticks. This is your fast feedback loop — keep it green as you build.
38
+
39
+ ## What's here
40
+
41
+ - `room.ts` — the authoritative contract: a genre-agnostic **demo-room lifecycle shell** (drop-in
42
+ join, take over an abandoned teammate, ready-up → rolling waves → lose → auto-restart) plus the
43
+ **tower-defense genre** below it. Replace the genre to build a different game on the same shell.
44
+ - `src/Game.tsx` — the React client, rendered on `@siltrun/stage` (PixiJS): `createStage` +
45
+ `cam.fitRect(arena)` + `tapBoard` is the whole ceremony — the arena fits any screen and
46
+ tap-to-place works identically on touch and mouse. Renders state, sends intent, derives
47
+ creep positions from the shared path.
48
+ - `AGENTS.md` + `skills/` — the teaching layer. Read these to extend the game.
49
+
50
+ ## Learn more
51
+
52
+ - Quickstart, the two lanes, the determinism realm, and the honest limits are the load-bearing
53
+ docs — read them before designing: https://silt.run/docs
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .DS_Store
@@ -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>silt · co-op tower defense</title>
10
+ </head>
11
+ <body style="margin: 0; background: #0B0B0D; color: #EAE7DE; font-family: 'Geist', system-ui, sans-serif;">
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.tsx"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "my-silt-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
+ "test": "bun test room.test.ts",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@siltrun/stage": "^0.1.0",
15
+ "pixi.js": "^8.19.0",
16
+ "@siltrun/client": "^0.3.0",
17
+ "@siltrun/react": "^0.1.0",
18
+ "react": "^18.3.0",
19
+ "react-dom": "^18.3.0"
20
+ },
21
+ "devDependencies": {
22
+ "@siltrun/room": "^0.1.0",
23
+ "@types/react": "^18.3.0",
24
+ "@types/react-dom": "^18.3.0",
25
+ "@vitejs/plugin-react": "^4.3.0",
26
+ "concurrently": "^9.0.0",
27
+ "siltrun": "^0.1.0",
28
+ "typescript": "^5.6.0",
29
+ "vite": "^5.4.0"
30
+ }
31
+ }
@@ -0,0 +1,159 @@
1
+ // room.test.ts — the contract is a deterministic pure function, so we verify it by
2
+ // driving tick() with scripted inputs and asserting truth. No browser, no network.
3
+ // bun test room.test.ts
4
+ import { test, expect } from "bun:test";
5
+ import contract, { type State, type Cmd } from "./room.ts";
6
+ import type { Ctx, Input } from "@siltrun/room";
7
+
8
+ // A deterministic Ctx. `random` is seeded + reproducible so spawn jitter is testable.
9
+ function makeCtx(tick: number, seed = 1): Ctx {
10
+ let s = seed >>> 0;
11
+ return {
12
+ tick, dt: 1 / 60, time: tick / 60,
13
+ random() { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; },
14
+ emit() {},
15
+ };
16
+ }
17
+
18
+ const join = (id: string): Input<Cmd> => ({ kind: "join", id });
19
+ const leave = (id: string, reason: "left" | "timeout" = "left"): Input<Cmd> => ({ kind: "leave", id, reason });
20
+ const ready = (from: string): Input<Cmd> => ({ kind: "event", from, data: { type: "ready" } });
21
+ const place = (from: string, slot: number, kind: number): Input<Cmd> => ({ kind: "event", from, data: { type: "place", slot, kind } });
22
+
23
+ /** Drive N ticks with an optional per-tick input supplier. Returns final state. */
24
+ function run(state: State | undefined, ticks: number, at: (t: number) => Input<Cmd>[] = () => [], t0 = 0): State {
25
+ let s = state as State;
26
+ for (let i = 0; i < ticks; i++) s = contract.tick(s, at(t0 + i), makeCtx(t0 + i)) as State;
27
+ return s;
28
+ }
29
+
30
+ // ── LIFECYCLE SHELL ──────────────────────────────────────────────────────────────────
31
+
32
+ test("first tick seeds a gathering room with empty seats", () => {
33
+ const s = run(undefined, 1);
34
+ expect(s.shell.phase).toBe("gathering");
35
+ expect(s.shell.seats.length).toBe(2);
36
+ expect(s.shell.seats.every((x) => x.id === null)).toBe(true);
37
+ });
38
+
39
+ test("a join claims an empty seat", () => {
40
+ const s = run(undefined, 1, (t) => (t === 0 ? [join("alice")] : []));
41
+ expect(s.shell.seats[0].id).toBe("alice");
42
+ });
43
+
44
+ test("ready-up of 2 players starts a game (gathering → playing)", () => {
45
+ const s = run(undefined, 3, (t) =>
46
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : []);
47
+ expect(s.shell.phase).toBe("playing");
48
+ expect(s.shell.round).toBe(1);
49
+ expect(s.wave).toBe(1);
50
+ expect(s.shell.seats.every((x) => !x.ready)).toBe(true); // ready flags cleared on start
51
+ });
52
+
53
+ test("one ready player is NOT enough to start", () => {
54
+ const s = run(undefined, 5, (t) =>
55
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice")] : []);
56
+ expect(s.shell.phase).toBe("gathering");
57
+ });
58
+
59
+ test("leaving during gathering frees the seat entirely", () => {
60
+ const s = run(undefined, 3, (t) =>
61
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [leave("alice")] : []);
62
+ expect(s.shell.seats[0].id).toBe(null);
63
+ expect(s.shell.seats[0].abandoned).toBe(false);
64
+ });
65
+
66
+ test("leaving DURING play abandons the seat (data kept), and a newcomer takes it over", () => {
67
+ // start a game
68
+ let s = run(undefined, 3, (t) =>
69
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : [], 0);
70
+ expect(s.shell.phase).toBe("playing");
71
+ // alice drops mid-game → seat 0 abandoned, not freed
72
+ s = contract.tick(s, [leave("alice", "timeout")], makeCtx(100)) as State;
73
+ expect(s.shell.seats[0].id).toBe("alice");
74
+ expect(s.shell.seats[0].abandoned).toBe(true);
75
+ // carol joins mid-game → takes over the abandoned seat (not a new seat; both were full)
76
+ s = contract.tick(s, [join("carol")], makeCtx(101)) as State;
77
+ expect(s.shell.seats[0].id).toBe("carol");
78
+ expect(s.shell.seats[0].abandoned).toBe(false);
79
+ expect(s.shell.phase).toBe("playing"); // game never paused
80
+ });
81
+
82
+ test("reconnect (same id) resumes the seat without a takeover", () => {
83
+ let s = run(undefined, 3, (t) =>
84
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : []);
85
+ s = contract.tick(s, [leave("alice", "timeout")], makeCtx(50)) as State;
86
+ expect(s.shell.seats[0].abandoned).toBe(true);
87
+ s = contract.tick(s, [join("alice")], makeCtx(51)) as State; // same id rejoins
88
+ expect(s.shell.seats[0].id).toBe("alice");
89
+ expect(s.shell.seats[0].abandoned).toBe(false);
90
+ });
91
+
92
+ test("a third joiner with no free seat spectates (no seat), then seats at next gathering", () => {
93
+ let s = run(undefined, 3, (t) =>
94
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : []);
95
+ s = contract.tick(s, [join("carol")], makeCtx(80)) as State; // both seats live → spectator
96
+ expect(s.shell.seats.some((x) => x.id === "carol")).toBe(false);
97
+ });
98
+
99
+ test("loss (lives to 0) rolls playing → gameover → a fresh gathering", () => {
100
+ let s = run(undefined, 3, (t) =>
101
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : []);
102
+ // force a loss without placing any towers: creeps will leak. Fast-forward enough ticks.
103
+ s = run(s, 4000, () => [], 3);
104
+ // after a loss the room is back to gathering with a fresh (idle) game and round preserved logic
105
+ expect(["gathering", "gameover"]).toContain(s.shell.phase);
106
+ // drive past the gameover countdown to be sure it resets
107
+ const cont = run(s, 400, () => [], 5000);
108
+ expect(cont.shell.phase).toBe("gathering");
109
+ expect(cont.wave).toBe(0); // game cleared on reset
110
+ });
111
+
112
+ // ── GENRE: authoritative placement ─────────────────────────────────────────────────
113
+
114
+ test("server validates tower placement: rejects occupied slot, bad slot, and unaffordable", () => {
115
+ let s = run(undefined, 3, (t) =>
116
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] : []);
117
+ const gold0 = s.gold;
118
+ s = contract.tick(s, [place("alice", 0, 0)], makeCtx(10)) as State; // valid
119
+ expect(s.towers.length).toBe(1);
120
+ expect(s.gold).toBe(gold0 - 50);
121
+ s = contract.tick(s, [place("bob", 0, 0)], makeCtx(11)) as State; // slot occupied → reject
122
+ expect(s.towers.length).toBe(1);
123
+ s = contract.tick(s, [place("bob", 99, 0)], makeCtx(12)) as State; // bad slot → reject
124
+ expect(s.towers.length).toBe(1);
125
+ // drain gold then attempt an unaffordable place
126
+ s.gold = 10;
127
+ s = contract.tick(s, [place("bob", 1, 1)], makeCtx(13)) as State; // costs 90 → reject
128
+ expect(s.towers.length).toBe(1);
129
+ });
130
+
131
+ // ── STATE BUDGET: the whole State must fit one ~1200B datagram (BOUNDARIES §1) ──────
132
+
133
+ test("worst-case state serializes under the datagram MTU budget", () => {
134
+ // Construct a maxed state: full seats, all slots towered, creep array at its cap.
135
+ let s = run(undefined, 3, (t) =>
136
+ t === 0 ? [join("aaaaaaaa"), join("bbbbbbbb")] : t === 1 ? [ready("aaaaaaaa"), ready("bbbbbbbb")] : []);
137
+ // fill every tower slot
138
+ for (let slot = 0; slot < s.towers.length + 8; slot++) {
139
+ s = contract.tick(s, [place("aaaaaaaa", slot, 1)], makeCtx(20 + slot)) as State;
140
+ s.gold = 9999; // keep affordable
141
+ }
142
+ // saturate creeps to the cap
143
+ while (s.creeps.length < 14) s.creeps.push({ i: s.next++, w: 2, t: 0.5, h: 99 });
144
+ s.cursors = { aaaaaaaa: { x: 0.123, y: 0.456 }, bbbbbbbb: { x: 0.789, y: 0.012 } };
145
+ const bytes = Buffer.byteLength(JSON.stringify(s), "utf8");
146
+ console.log(`worst-case state = ${bytes}B (budget ~1100)`);
147
+ expect(bytes).toBeLessThan(1100);
148
+ });
149
+
150
+ // ── DETERMINISM: same inputs + same ctx → identical state ──────────────────────────
151
+
152
+ test("tick is deterministic — identical scripted runs produce identical state", () => {
153
+ const script = (t: number): Input<Cmd>[] =>
154
+ t === 0 ? [join("alice"), join("bob")] : t === 1 ? [ready("alice"), ready("bob")] :
155
+ t === 5 ? [place("alice", 0, 0), place("bob", 4, 1)] : [];
156
+ const a = run(undefined, 300, script);
157
+ const b = run(undefined, 300, script);
158
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
159
+ });