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,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,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,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,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,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
|
+
});
|