@netforge/template-survivor 0.1.0 → 0.2.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/AGENTS.md ADDED
@@ -0,0 +1,62 @@
1
+ # Extending EMBERWATCH
2
+
3
+ EMBERWATCH is the survivor-like co-op template of the Net Forge game engine. This file tells a coding agent how to change the game safely. `llms.txt` in this package has the full recipe book for this genre, and `llms-full.txt` adds the whole `src/game.ts` as a worked example.
4
+
5
+ ## The one file to edit
6
+
7
+ Change the game by editing `src/game.ts` only. It holds declarations, not engine code. client/names.ts holds the display labels for weapon, upgrade and earn ids. In a downloaded game folder, labels players read live in src/names.json. Everything else (the client, the manifest, the store hooks, the simulation, the netcode) is template machinery.
8
+
9
+ ## Import only @netforge/engine
10
+
11
+ `src/game.ts` imports from `@netforge/engine` and nothing else. The template's game imports:
12
+
13
+ ```ts
14
+ import { achievement, director, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
15
+ ```
16
+
17
+ Also available for this genre: `economy`, `metaUpgrade`. `earn` has `earn.rule(...)` and `earn.stake(...)`; `store` has `store.sku(...)`. `system(id, ctx => ...)` gets the systems API described in `llms.txt`. The renderer, input, netcode and physics live elsewhere and are not reachable from game code.
18
+
19
+ ## Rules the build enforces
20
+
21
+ - Ids are lower_snake_case.
22
+ - Every game declares at least one `earn.rule(...)` (mandatory earn) and one `earn.stake(...)` (the buy-in run), on one top-level line. A game without either does not build.
23
+ - Asset ids are library ids such as `style.lowpoly.turret`, never URLs or file paths, and must exist in the asset library.
24
+ - A survivor game needs one enemy prefab, one weapon and `players.weapon`.
25
+ - Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
26
+ - `game({ style })` is the art style the creator picked (lowpoly, toon, ps1, pixel, voxel or handdrawn). Keep it.
27
+ - Keep the store SKUs the client is wired to, at their prices: revive and skin_aurora (and the ember_charm gear). "stake" is reserved and is never a SKU id.
28
+
29
+ ## Validate
30
+
31
+ - With the MCP server (`npx -y @netforge/mcp`, bin `netforge-mcp`): call `netforge_validate_game` on the game. It returns one line per problem.
32
+ - In this package: `npx vitest run` runs `test/harness.test.ts`, `test/host.test.ts`, `test/store.test.ts`. A bad declaration throws `GameDefinitionError` with a line you can act on.
33
+
34
+ ## Playtest
35
+
36
+ - With the MCP server: call `netforge_run_playtest`.
37
+ - In code: `import { engine } from "@netforge/engine"` and run `engine.test.run({ ticks: 30 * 180, bots: 4, assert: [] })`. It reports kills, wave, maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"` a human-like bot plays and `report.fun` holds the fun metrics the gate scores (first threat, first level, downs before 1:00, spawns on screen and more).
38
+
39
+ ## Play locally
40
+
41
+ ```sh
42
+ mkdir -p my-game/src && cd my-game
43
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-survivor
44
+ cp node_modules/@netforge/template-survivor/src/game.ts src/
45
+ echo '{"template":"survivor"}' > netnet.json
46
+ npx kit-play # opens http://127.0.0.1:4663
47
+ ```
48
+
49
+ The MCP tool `netforge_play_locally` does the same. Coins are play money; nothing pays out and no chain or wallet is involved. Edit `src/game.ts` and run it again to try a change.
50
+
51
+ ## What not to do
52
+
53
+ - Do not write renderers, netcode, physics or bot AI. The template already has them.
54
+ - Do not use asset URLs or file paths. Use library ids.
55
+ - Do not import anything other than `@netforge/engine` in `src/game.ts`.
56
+ - Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
57
+ - Do not rename or reprice the store SKUs the client is wired to.
58
+ - Do not spawn enemies on screen; the director brings them in just off screen.
59
+
60
+ ## Docs
61
+
62
+ https://forge.netnet.capital/docs/templates/survivor
package/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # CLAUDE.md
2
+
3
+ Read AGENTS.md before changing this game.
4
+
5
+ The five hard rules:
6
+
7
+ 1. Edit only `src/game.ts`.
8
+ 2. Import only `@netforge/engine`.
9
+ 3. Ids are lower_snake_case.
10
+ 4. Declare at least one `earn.rule(...)` and exactly one `earn.stake(...)`.
11
+ 5. No renderers, netcode, physics or asset URLs.
package/README.md CHANGED
@@ -1,41 +1,74 @@
1
- # EMBERWATCH (Template 1: survivor-like co-op)
1
+ # @netforge/template-survivor
2
2
 
3
- Four keepers hold a field at night against a swarm that grows every wave,
4
- up to 2,000 at once. Weapons fire by themselves, gems level you up, pick
5
- one of three upgrades, stand by a downed teammate to revive them, survive
6
- ten waves and break the Bell Warden.
3
+ A survivor-like co-op game template for Net Forge: 4 players against up to 2,000 enemies at once (EMBERWATCH).
7
4
 
8
- | Path | What |
9
- |---|---|
10
- | `src/game.ts` | The game, as the model writes it (declarations only). |
11
- | `src/manifest.ts` | Loads and compiles it; `survivorAdapter()` for the room server and client. |
12
- | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive (50 coins) and a cosmetic (300 coins), shown under the token's own symbol and logo; mock until the token is live. |
13
- | `client/` | The browser client: three.js scene, HUD, input (keys, gamepad, touch), game feel. |
14
- | `test/` | Playtest bots through `engine.test.run`. |
15
- | `llms.txt` | How to change the game (for agents). |
5
+ Four keepers hold a field at night against a swarm that grows every wave. Weapons fire by themselves, gems level you up, you pick one of three upgrades, you stand by a downed teammate to revive them, and the team survives ten waves and breaks the Bell Warden.
6
+
7
+ The room server is authoritative at 30 Hz. Your own keeper is predicted. The spawn director brings every enemy in just off screen on the run's clock, with curves for the pacing.
16
8
 
17
- ## Why a package here
9
+ Earn: waves cleared (2 and 7), the boss killed, and 500 kills, once per run each. Real-token earn and stakes run only on the hosted Net Forge server.
18
10
 
19
- It is a game, not engine code: Net Forge copies it as the starting repo
20
- for a generated game. The room server loads its game module and a browser
21
- bundle is its client, so it is its own package, depending on
22
- `@netforge/engine` and `-sim`.
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install @netforge/template-survivor
15
+ ```
23
16
 
24
- ## Run it
17
+ You normally do not import this package yourself. `kit play` runs it on your own computer, and the Net Forge MCP server and studio copy its `src/game.ts` as the starting point for a new game.
25
18
 
26
- This template runs on your own computer with play money through
27
- `kit play` (`@netforge/kit-play`). From the repository root:
19
+ ## Play it locally
28
20
 
29
21
  ```sh
30
- pnpm install --frozen-lockfile && pnpm build
31
- mkdir -p play-survivor/src && cp packages/template-survivor/src/game.ts play-survivor/src/
32
- echo '{"template":"survivor"}' > play-survivor/netnet.json
33
- node packages/kit-play/dist/cli.js play-survivor # opens http://127.0.0.1:4663
22
+ mkdir -p my-game/src && cd my-game
23
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-survivor
24
+ cp node_modules/@netforge/template-survivor/src/game.ts src/
25
+ echo '{"template":"survivor"}' > netnet.json
26
+ npx kit-play # opens http://127.0.0.1:4663
34
27
  ```
35
28
 
36
- Edit `play-survivor/src/game.ts` and run it again to try a change. Real-token
37
- earning, stakes and claims run only on the hosted Net Forge server.
29
+ Coins are play money and nothing pays out. Real-token earning, stakes and claims run only on the hosted Net Forge server.
30
+
31
+ ## Change the game
32
+
33
+ The ember nova is the starting weapon. Make it hit harder and reach further:
34
+
35
+ ```ts
36
+ weapon("ember_nova", {
37
+ kind: "nova", damage: 16, cooldown: 1.1, range: 4.4, knockback: 2.5,
38
+ // levels and evolve stay as they are
39
+ });
40
+ ```
41
+
42
+ Save `src/game.ts` and run `npx kit-play` again.
43
+
44
+ ## Files
45
+
46
+ | Path | What |
47
+ |---|---|
48
+ | `src/game.ts` | The game, declarations only: the field, the swarm, weapons, upgrades, the director, systems, earn, store. |
49
+ | `src/manifest.ts` | Loads and compiles the game; `survivorAdapter()` and `survivorGame()` for the room host and the client. |
50
+ | `src/store.ts` | Store hooks on the Kit's names: a revive (50 coins), a cosmetic (300 coins) and the ember charm gear (150 coins). |
51
+ | `client/` | The browser client: three.js scene, HUD, input (keys, gamepad, touch), game feel. `client/names.ts` holds display labels. |
52
+ | `test/` | Playtest bots through `engine.test.run`, the local host and the store. |
53
+ | `llms.txt` | How to change the game, for agents. |
54
+
55
+ ## Controls
56
+
57
+ Keys: WASD or arrows move, Space dash, 1 2 3 pick a card. Touch: drag on the left half, the DASH button, tap a card. The backquote key shows stats. Open the Invite link in another tab or on a phone on the same network to play together (`?room=<id>`).
58
+
59
+ ## For AI agents
60
+
61
+ Read `AGENTS.md` and `llms.txt` in this package before changing the game. `llms-full.txt` adds the whole `src/game.ts` as a worked example.
62
+
63
+ Agents can also drive this template through the Net Forge MCP server: run `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `netforge-mcp`). Its tools are `netforge_list_templates`, `netforge_create_game`, `netforge_explain_api`, `netforge_search_docs`, `netforge_validate_game`, `netforge_run_playtest` and `netforge_play_locally`.
64
+
65
+ To set up a coding agent in a game project, run `npx netforge setup agent`. It writes AGENTS.md, CLAUDE.md, Cursor rules and `.mcp.json` into the project. In Claude Code you can also add the server directly with `claude mcp add netforge -- npx -y @netforge/mcp`.
66
+
67
+ ## Docs
68
+
69
+ - https://forge.netnet.capital/docs/templates/survivor
70
+ - `llms.txt` and `llms-full.txt` in this package (also exported as `@netforge/template-survivor/llms.txt` and `@netforge/template-survivor/llms-full.txt`)
71
+
72
+ ## License
38
73
 
39
- Open the Invite link in another tab or on a phone on the same network to
40
- play together (`?room=<id>`). Keys: WASD or arrows, Space dash, 1 2 3 pick.
41
- Touch: drag on the left half, DASH button, tap a card. ` shows stats.
74
+ MIT. See `LICENSE` and `NOTICE`.
package/client/main.ts CHANGED
@@ -159,7 +159,7 @@ async function main(): Promise<void> {
159
159
  const joinMs = performance.now() - joinStart;
160
160
  if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("That room was full, started a new one"), 500);
161
161
  if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("A new version is live. Reload to update."), 800);
162
- history.replaceState(null, "", `${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${autopilot ? "&autopilot=1" : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
162
+ history.replaceState(null, "", `${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}${autopilot ? "&autopilot=1" : ""}${params.has("webgl") ? "&webgl=1" : ""}${params.has("stats") ? "&stats=1" : ""}${params.has("style") ? `&style=${encodeURIComponent(params.get("style") ?? "")}` : ""}`);
163
163
 
164
164
  // The engine's feel kit: hit-stop clock, trauma shake, flash, knockback, numbers, sound, rumble.
165
165
  const feelParam = params.get("feel");
@@ -232,7 +232,7 @@ async function main(): Promise<void> {
232
232
  revive: () => void paidRevive(),
233
233
  getUp: () => input.getUp(),
234
234
  invite: () => {
235
- const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
235
+ const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}${params.has("game") ? `&game=${encodeURIComponent(params.get("game") ?? "")}` : ""}`;
236
236
  void navigator.clipboard?.writeText(url).then(
237
237
  () => hud.toast("Invite link copied"),
238
238
  () => hud.toast(url),
package/llms-full.txt ADDED
@@ -0,0 +1,738 @@
1
+ # Net Forge survivor template (EMBERWATCH)
2
+
3
+ > A co-op survivor-like for 4 players and up to 2,000 enemies. You change
4
+ > the game by editing src/game.ts only. The template already has waves,
5
+ > weapons, XP gems, level-ups, bosses, co-op revive, netcode, rendering,
6
+ > touch controls, earn and the store. Do not write renderers, netcode or
7
+ > physics.
8
+
9
+ ## Files
10
+
11
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
12
+ game(), prefab(), weapon(), upgrade(), director(), system(), earn.rule(),
13
+ store.sku().
14
+ - packages/engine/GAME-DESIGN.md: the design rules the playtest gate scores
15
+ (quiet opening, off-screen spawns, i-frames, telegraphs, fast levels).
16
+ - client/names.ts: display names for weapon, upgrade and earn ids (labels).
17
+ - Models come from the asset library (real CC0 models; the full list of ids is in your instructions). client/library.ts holds the fallback shapes drawn until a model loads.
18
+ - Everything else is template machinery.
19
+
20
+ ## Rules the build enforces
21
+
22
+ - Asset ids look like "style.lowpoly.ghost", never URLs or paths, and must be in the asset library.
23
+ - game({ style }) is the art style the creator picked: lowpoly, toon, ps1, pixel, voxel or handdrawn. Keep it. The template draws everything in it, and a "style.lowpoly.<name>" id draws as that style's own model of the same name when the style has one.
24
+ - Ids are lower_snake_case.
25
+ - Every game declares at least one earn.rule (mandatory earn) and one earn.stake (the buy-in run).
26
+ - A survivor game needs one enemy prefab, one weapon and players.weapon.
27
+ - Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
28
+ - Run `pnpm --filter @netforge/template-survivor test` (playtest bots).
29
+
30
+ ## Units
31
+
32
+ Seconds, metres, metres per second, hit points. Tick rate 30 Hz. Keepers
33
+ run at 7 m/s; keep chasers at 3-4 m/s and fast ones under 6.
34
+
35
+ ## Pacing: the spawn director
36
+
37
+ The director spawns every enemy just off screen (never on it) on the run's
38
+ clock. Curves are a number or [seconds, value] keyframes, linear between.
39
+
40
+ director({
41
+ start: 8, // enemies at 0:00
42
+ alive: [[0, 12], [60, 45], [180, 160], [300, 400], [540, 1600]], // living target
43
+ rate: [[0, 0.8], [60, 2], [180, 6], [300, 14], [540, 60]], // spawns per second
44
+ health: [[0, 1], [60, 1.4], [180, 2.4], [540, 5.7]], // new enemies' health x
45
+ damage: [[0, 1], [180, 1.45], [540, 2.3]],
46
+ xp: [[0, 1], [540, 2]],
47
+ pool: [
48
+ { prefab: "shade", weight: 10 },
49
+ { prefab: "gnat", from: 45, weight: 2, group: [8, 12] }, // a stream from one side
50
+ { prefab: "husk", from: 90, weight: 3 },
51
+ ],
52
+ fade: { after: 260, weight: 0.3 }, // old enemy types thin out
53
+ });
54
+
55
+ speed: [[0, 1], [240, 1], [600, 1.6]] makes every non-boss enemy faster late
56
+ in the run, so endless kiting stops working.
57
+ More players raise alive and rate by perPlayer (0.6 each); a living boss
58
+ halves the rate (whileBoss). At the cap new spawns get tougher instead of
59
+ adding bodies (crowded). Enemies left far behind are moved back ahead of
60
+ the players (recycle). ahead (0.35) is the share spawned where you are going.
61
+
62
+ ## Common changes
63
+
64
+ ### Make it harder or easier
65
+ Raise or lower the director's alive, rate, health or damage keyframes after
66
+ the first minute; keep 0:00 quiet. Easier also: players.hitIframes (0.5 s),
67
+ players.magnet (4 m), a longer starting weapon range.
68
+
69
+ ### Add an enemy
70
+ prefab("wisp", { model: "style.lowpoly.bat", health: 8, speed: 5, radius: 0.25, damage: 4, ai: "chase", drops: ["ember_small"] });
71
+ then add it to the director's pool from the minute it should join:
72
+ { prefab: "wisp", from: 150, weight: 3 }
73
+ or spawn a set piece from a system, just off screen:
74
+ after(240, () => spawnEdge("wisp", { count: 60, side: "north" }));
75
+ ai is the enemy's role. Every big hit is telegraphed on the ground first:
76
+ - "chase" (default), "tank" (a chaser; give it mass), "idle"/"none" (stands still)
77
+ - "runner": aims where you will be. Fast, fragile.
78
+ - "charger": stops inside 6 m, shows a lane 0.55 s, then charges along it.
79
+ - "ranged": keeps its distance, shows an aim line, fires a slow dodgeable shot
80
+ (only with a clear line: pillars are cover).
81
+ - "bomber": walks up, arms a 1 s fuse circle, explodes (hurts enemies too).
82
+ - "shielder" / "healer": every few seconds shields or heals nearby allies.
83
+ - "summoner": keeps away and calls adds: attack: { summon: "gnat", count: 2 }.
84
+ - "flee", "wander".
85
+ Tune a role with attack: { damage, range, windup, cooldown, speed, radius, summon, count }:
86
+ prefab("spitter", { model: "style.lowpoly.frog", health: 20, speed: 2.8, ai: "ranged", attack: { damage: 8, range: 10, speed: 7 }, drops: ["ember_small"] });
87
+ A splitter bursts into smaller enemies on death:
88
+ prefab("brood", { model: "style.lowpoly.slime_green", health: 40, speed: 3, split: { into: "shade", count: 3 }, drops: ["ember_small"] });
89
+
90
+ ### Add elites
91
+ director({ ..., elites: { from: 90, every: [45, 60] } }) spawns an elite from the
92
+ current pool: x6 health, x1.5 damage, x5 XP, a gold rim. From a system:
93
+ spawnEdge("husk", { count: 1, elite: true }).
94
+
95
+ ### Add a weapon
96
+ weapon("frost_ring", {
97
+ kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
98
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { knockback: 3 }],
99
+ });
100
+ kinds:
101
+ - "nova": a burst in range; "aura": a field that burns what is inside, every cooldown
102
+ - "bolt": count projectiles at the nearest enemy (speed, pierce)
103
+ - "orbit": count blades at range metres, speed rad/s
104
+ - "cone": a sweep of spread degrees where the keeper faces (360: all around)
105
+ - "chain": strikes the nearest, then jumps count times, each within speed m
106
+ - "meteor": count circles of blast m land on enemies in range, 0.6 s after showing
107
+ - "boomerang": flies out range m and back, hitting everything both ways
108
+ levels: what each level changes from the one before (5-8 levels). A number
109
+ adds (count: 1, pierce: 2, cooldown: -0.1 s), a percent scales ("+40%").
110
+ Cards show these changes ("+1 projectile, -20% cooldown"). It is offered at
111
+ level-up automatically. Add a label in client/names.ts.
112
+
113
+ ### Add an evolution
114
+ weapon("frost_ring", { ..., evolve: { into: "blizzard", requires: "iron_skin" } });
115
+ weapon("blizzard", { kind: "nova", damage: 20, cooldown: 0.6, range: 9, knockback: 12 });
116
+ At max level, with the passive "iron_skin" taken, the "blizzard" card always
117
+ shows; it replaces frost_ring in its slot. Evolutions are never offered on
118
+ their own. Give them about x1.7 damage and a new shape (360 degrees, double
119
+ radius, more count). Pair each base weapon with a different passive.
120
+
121
+ ### Add a passive
122
+ upgrade("iron_skin", { stat: "armor", step: 1, maxLevel: 4 });
123
+ stats: speed, maxHealth, magnet, might, haste, regen, armor, area (bigger
124
+ novas, auras, cones, meteors and orbits), amount (more projectiles, blades,
125
+ jumps and meteors). Up to 8. maxLevel caps how often it is offered.
126
+
127
+ ### Add pickups and loot
128
+ prefab("hearth_meal", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
129
+ A boss's hoard: pickup: { effect: "chest", amount: 3, evolve: true }. Bosses
130
+ drop it instead of the plain chest. If the player holds a weapon and already
131
+ has its paired passive, that weapon jumps to max level, so its evolution is
132
+ on the cards.
133
+ effects: heal (amount: fraction of max health, or hit points above 1),
134
+ vacuum (every gem on the floor comes to you), bomb (clears the screen's
135
+ non-boss enemies), chest (amount card picks; a ready evolution shows first).
136
+ Drop them with a chance: drops: ["ember_small", { id: "hearth_meal", chance: 0.012 }].
137
+ Elites always drop the game's chest; bosses drop a heal, a vacuum and a chest.
138
+ Above 300 gems on the floor, new gems merge into the nearest one.
139
+
140
+ ### Add props (breakables)
141
+ prefab("ember_urn", { model: "style.lowpoly.barrel", health: 25, speed: 0, radius: 0.5, prop: true, drops: ["ember_mid", { id: "hearth_meal", chance: 0.3 }] });
142
+ then scatter("ember_urn", 45) from a system. Props never move, never hurt
143
+ and never count as enemies.
144
+
145
+ ### A bigger field
146
+ game({ ..., field: { halfSize: 100, pillars: [[14, 14, 2], [32, 0, 3], ...] } })
147
+ (20-120 m; pillars are [x, z, radius]).
148
+
149
+ ### Add events and moments
150
+ system("events", ({ every, after, announce, spawnEdge, xpBoost, surge, clear, bossAlive, random }) => {
151
+ after(240, () => every(75, () => {
152
+ if (bossAlive()) return; // events wait for bosses
153
+ announce("THE TIDE RISES", { tone: "danger" }); // a big banner for everyone
154
+ spawnEdge("shade", { count: 220, side: "north", spread: 80 });
155
+ surge(1.5, 20); // or xpBoost(2, 30), pauseDirector(10)
156
+ }));
157
+ });
158
+ Never fire the same event twice in a row. announce tones: info, danger,
159
+ reward. Off-screen bosses get an edge arrow automatically. Game events you
160
+ can key on (and earn rules too): miniboss.killed, boss.killed,
161
+ player.evolved, heal.collected, vacuum.collected, bomb.collected,
162
+ chest.collected. clear("lantern_thief") removes a prefab at once (an event
163
+ ends) with no kills or drops.
164
+
165
+ ### Cards
166
+ Weighted: an owned weapon's next level 8, a new weapon 10 (while a slot is
167
+ free), a passive 4 (7 once weapon slots are full); a ready evolution always
168
+ shows. players.rerolls (default 1) rerolls per run (R); Skip (X) trades the
169
+ cards for a small heal. When everything is maxed, a level heals and gives
170
+ teammates a little XP.
171
+
172
+ ### Add a boss
173
+ prefab("lantern_eater", {
174
+ model: "style.lowpoly.trex", appearance: { tint: "#c2410c", scale: 1.2 }, health: 30000, speed: 2, radius: 2, damage: 40,
175
+ boss: { attacks: ["slam", "ring", "dash", "summon"], summon: "shade", enrageAt: 0.5 },
176
+ drops: ["ember_large"],
177
+ });
178
+ after(480, () => spawnEdge("lantern_eater", { count: 1 })) from a system;
179
+ on("boss.killed", () => end("won")).
180
+ Attacks (all telegraphed): slam (circle around it), dash (11 m lane), leap
181
+ (lands on a circle at you), rain (circles near players), ring (14 shots
182
+ outward), fan (7 shots at you), summon (adds). It switches near and far
183
+ modes every 4-9 s; below enrageAt health it is faster and throws more.
184
+ A mini-boss: boss: { attacks: [...], rank: "mini" } raises "miniboss.killed"
185
+ (not "boss.killed", so it never ends the run). Boss health scales with
186
+ players present (players.bossHealthPerPlayer, 0.55); every boss gets a HUD
187
+ bar and drops a burst of gems worth 1-2 levels.
188
+
189
+ ### Change pacing
190
+ The director's curves set the crowd; BOSS_AT sets the boss. A wave on the
191
+ HUD is a minute: the run system emits wave.cleared every 60 s (earn rules
192
+ count it). The XP curve is players.xp { base, growth, exponent }: XP to go
193
+ from L to L+1 is base + growth x (L - 1)^exponent. Aim for level 2 within
194
+ 10 s, level 5 at 1:00, level 10 at 3:00.
195
+
196
+ ### Add an earn rule
197
+ earn.rule("survivor_5", { when: "wave.cleared", n: 5, pay: 10, oncePer: "run" });
198
+ Events: wave.cleared (you emit it), enemy.killed (credits the killer),
199
+ boss.killed (everyone present), player.downed, player.revived (credits the
200
+ reviver), player.levelup, run.won, run.lost, and anything you emit().
201
+ Each rule pays once per run per player, in coins; the room server is the referee.
202
+
203
+ ### The buy-in run (required)
204
+ earn.stake({ min: 20, max: 200, multiple: 2 });
205
+ Every game declares one, on one top-level line; a game without it does not
206
+ build. A player stakes coins at the start of a run (the Kit spends them as
207
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
208
+ fixes the stake when the next run starts. A staked run pays its normal earn
209
+ plus a share of stake x multiple: the share is the part of the run's most
210
+ earn the player makes. What it does not win back stays in the reward pool.
211
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
212
+
213
+ ### Losable gear (optional)
214
+ store.sku("ember_charm", { coins: 150, grants: { gear: "kindling", loseBelow: 0.25 } });
215
+ Gear is owned in the player's profile and sells for coins only. It brings one
216
+ level of its declared upgrade into every staked run, and is lost when that run
217
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
218
+
219
+ ### Coins: earn and the store
220
+
221
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
222
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
223
+ as a label). Earn rules pay coins (`pay` is whole coins). The recommended
224
+ coin model (the default, no declaration) sells cosmetics, unlocks and
225
+ in-run consumables, never permanent stat power:
226
+
227
+ store.sku("revive", { coins: 50 }); // in-run: used while downed
228
+ store.sku("skin_aurora", { coins: 300, grants: { cosmetic: 1 } }); // a look, owned for good
229
+
230
+ Keep both exactly: the client is wired to them (price and look id). Owned
231
+ looks and unlocks are saved on the room server in the player's profile. Players spend
232
+ earned coins first (instantly, no wallet prompt), then their wallet. Before
233
+ the game launches coins are play money; the Kit switches by itself, so
234
+ nothing in game.ts depends on it. Price in coins against what a run pays
235
+ (the four rules here pay up to 80 a run): a revive is under a run, a
236
+ cosmetic a few runs.
237
+
238
+ ### Sell a keeper unlock (a character, a starting loadout, a sidegrade)
239
+ keeper("tinker", { name: "Tinker", text: "Turrets and a wide glass.", weapon: "spark_bolt", passive: "lantern_glass" }); // no unlock
240
+ store.sku("unlock_tinker", { coins: 400, grants: { keeper: "tinker" } });
241
+ A keeper is earned (unlock: an achievement) or bought (a SKU), never both:
242
+ the build refuses a sold keeper that also declares an unlock. Never sell
243
+ the first keeper (everyone's free default). An unlock is a different way to
244
+ play, never more power. Before launch coins are play money and buy only
245
+ in-run items; looks and unlocks open at launch.
246
+
247
+ ### Cosmetics only ("no pay to win")
248
+ economy({ store: "cosmetics" });
249
+ Then every SKU must be a look (grants: { cosmetic: n }); drop revive.
250
+
251
+ ### Permanent upgrades for coins (opt-in meta upgrades)
252
+ economy({ metaUpgrades: true });
253
+ metaUpgrade("hearth", { stat: "maxHealth", step: 0.05, levels: 3, cost: 120, growth: 2 }); // 120, 240, 480 coins
254
+ metaUpgrade("stride", { stat: "speed", step: 0.03, levels: 3, cost: 100, growth: 1.8 });
255
+ Only when the creator asks for permanent upgrades. Stats: speed, maxHealth,
256
+ magnet, might, haste, area. The engine refuses more than +15% to one stat
257
+ or +30% in all, more than 5 levels, or growth under 1.5, and applies a
258
+ player's levels at the start of every run. The store shows each level.
259
+
260
+ ### Add a keeper (a playable character)
261
+ keeper("sparkwright", { name: "Sparkwright", text: "Bolts at range, and quicker hands.", model: "style.lowpoly.rogue", tint: "#7ad7ff", weapon: "spark_bolt", passive: "bellows", unlock: "five_minutes" });
262
+ A keeper starts every run with its weapon and its passive at level 1;
263
+ players pick one before their first level and on the end screen. At most
264
+ 15; the first declared keeper is everyone's default, and at least one
265
+ keeper has no unlock (declare a free one first). Keepers unlock with
266
+ achievements, or with coins through a SKU with grants: { keeper }. The room
267
+ server checks every pick against the player's saved unlocks.
268
+ With keepers declared, players.weapon is optional.
269
+
270
+ ### Add achievements, daily goals and end-screen titles
271
+ achievement("evolver", { title: "Evolved", text: "Evolve a weapon.", when: "player.evolved" });
272
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
273
+ title("butcher", { text: "The Butcher", most: "kills" });
274
+ Achievements and missions count a game event per player in one run (n
275
+ times) and pay nothing (coins come only from earn rules). The room server
276
+ grants them from the run and saves them per player (wallet, or guest until
277
+ sign-in), so they follow the player to every device. The HUD shows
278
+ three missions a day. Titles rank players at the end of a run by most or
279
+ least of: kills, damage, revives, downs, level, pickups. Events to count:
280
+ anything you emit ("tide.survived"), "<prefab>.killed"
281
+ ("lantern_thief.killed"), "elite.killed", "player.evolved",
282
+ "player.revived", "chest.collected", "miniboss.killed", "boss.killed",
283
+ "wave.cleared".
284
+
285
+ ### Co-op that feels shared
286
+ players: { share: "nearby" } // default: half of each gem to teammates within 20 m
287
+ players: { share: "all" } // or "collector", or { mode, fraction, radius }
288
+ players: { reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 } } // the reviver burns hotter
289
+ players: { downed: { seconds: 10, crawl: 0.35, selfRevives: 1, respawn: 5 } } // the default: a short down, a crawl, one free get-up (Space), back in 5 s beside a teammate
290
+ players: { lateJoinBehind: 2 } // a late joiner starts 2 levels under the team, cards waiting
291
+ players: { crit: { chance: 0.08, multiplier: 2 } } // crits show as gold numbers
292
+ The end screen votes "Play again" (everyone ready starts the next run; it
293
+ starts by itself after 10 s) and shows each keeper's build, damage per
294
+ weapon, what downed them, coins and titles.
295
+
296
+ ### Make co-op 8 players
297
+ players.max: 8. The hosted Net Forge room server seats at most 8 in a
298
+ co-op room, so 8 is the most a game can ask for.
299
+
300
+ ## Models and appearances (the asset library)
301
+
302
+ Every model is a real CC0 model from the asset library, by id. Enemies are
303
+ drawn about 3.2 hit radii tall, keepers 1.8 m; animated ones walk, run and
304
+ die by themselves. An appearance dresses a model into your own original:
305
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
306
+
307
+ ### Make enemies look like slimes
308
+ prefab("shade", { model: "style.lowpoly.slime_green", ... });
309
+ prefab("husk", { model: "style.lowpoly.slime_spiky", ... });
310
+ prefab("brute", { model: "style.lowpoly.slime_pink", appearance: { scale: 1.3 }, ... });
311
+
312
+ ### Add a monster truck (or any vehicle)
313
+ prefab("crusher", { model: "style.lowpoly.monster_truck", health: 400, speed: 3, radius: 1.2, damage: 20, ai: "chase", mass: 10, drops: ["ember_mid"] });
314
+
315
+ ### A hero described by the creator
316
+ players: { prefab: "style.lowpoly.worker", appearance: { tint: "#2e7d32", accent: "#3b2a1a", parts: ["mustache", "hat_cap"] }, ... }
317
+ Name it something new in names.json. A creature the library lacks: the
318
+ nearest base plus parts, e.g. model "style.lowpoly.shiba" with
319
+ appearance { tint: "#5ec8ff", parts: ["antenna", "tail_bolt"] }.
320
+
321
+ ## Look and feel (engine render stack and feel kit)
322
+
323
+ How the game looks and how hits feel are declarations too. They are
324
+ presentation only (never the simulation), so tune them freely. The full
325
+ reference is packages/engine/llms.txt.
326
+
327
+ ### Change the look
328
+ game({ ..., look: { preset: "dusk", bloom: 1.4, fog: 0.3, tint: "#ffd9a0" } });
329
+ presets: "night" (default), "dusk", "day", "studio". Also shadows (off here unless
330
+ look.shadows is true), outline,
331
+ exposure, saturation, contrast, vignette.
332
+
333
+ ### Make a death or a hit feel bigger
334
+ prefab("brute", { ..., feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
335
+ Without feel, big enemies (bosses, radius 0.5 m or more) burst, scorch and
336
+ shake, and small ones burst, splat and crunch.
337
+
338
+ ### Make a weapon's impacts feel different
339
+ weapon("frost_ring", { ..., feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
340
+ Novas use it when they go off; bolts and lances when they hit.
341
+
342
+ ### Heavier or lighter knockback
343
+ weapon knockback (m/s) pushes enemies, divided by their mass. Knocked
344
+ enemies stagger, slide, bounce off pillars and shove the enemies behind
345
+ them. Bolts stop at pillars.
346
+
347
+ ## Systems API
348
+
349
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z], { elite? }),
350
+ spawnPickup(prefab, [x,y,z]), announce(text, { tone }), bossAlive(),
351
+ pauseDirector(seconds), surge(multiplier, seconds), xpBoost(multiplier, seconds),
352
+ scatter(prefab, count), clear(prefab), upcoming(label, seconds) (a HUD countdown:
353
+ upcoming("THE BELL WARDEN", 240)),
354
+ spawnEdge(prefab, { count, around?, side?, spread?, scale? }) (just off screen;
355
+ side: "any" | "ahead" | "behind" | "north" | "south" | "east" | "west"),
356
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
357
+ emit(event), on(event, players => ...), respawnDead(), end("won" | "lost"),
358
+ random(), tick(), time().
359
+
360
+ ## Test
361
+
362
+ engine.test.run({ ticks: 30 * 180, bots: 4, assert: [...] }) plays the game
363
+ on the server sim with bots and reports kills, wave, maxLevel, earned by
364
+ rule, deaths, runs won and lost, softlock and ms per tick. With
365
+ bot: "human" a human-like bot plays (200 ms reactions, kiting, a second per
366
+ card) and report.fun has the fun metrics: first threat, first level,
367
+ level at 1/3/6/9 minutes, downs before 1:00, enemies on screen at 0:30,
368
+ touches in minute 1, spawns on screen, survival. Net Forge's gate scores
369
+ them; `pnpm --filter @netforge/template-survivor fun` prints the table.
370
+
371
+ ## Worked example: src/game.ts
372
+
373
+ The whole game as the template ships it. Every change starts from this file.
374
+
375
+ ```ts
376
+ /**
377
+ * EMBERWATCH: the survivor-like co-op template.
378
+ *
379
+ * One lantern against the dark, growing into a bonfire. Four keepers hold
380
+ * a field at night. The opening is a handful of shades; the swarm grows by
381
+ * the minute until thousands flood the field, and at ten minutes the Bell
382
+ * Warden comes. Weapons fire by themselves; gems from fallen enemies level
383
+ * you up; pick one of three upgrades; stand next to a downed teammate to
384
+ * revive them.
385
+ *
386
+ * This file is the whole game as the model writes it: declarations only,
387
+ * against the typed API in @netforge/engine. Everything else (sim,
388
+ * netcode, renderer, referee) is the template's machinery. Design rules:
389
+ * packages/engine/GAME-DESIGN.md.
390
+ */
391
+ import { achievement, director, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
392
+
393
+ export default game({
394
+ template: "survivor",
395
+ style: "lowpoly",
396
+ places: { field: { chunks: "places/field", spawn: [0, 1, 0] } },
397
+ // A 150 x 150 m field: room to move, with stone pillars to kite around.
398
+ field: {
399
+ halfSize: 75,
400
+ pillars: [
401
+ [14, 14, 2], [-14, 14, 2], [14, -14, 2], [-14, -14, 2],
402
+ [32, 0, 3], [-32, 0, 3], [0, 32, 3], [0, -32, 3],
403
+ [55, 40, 3], [-55, 40, 3], [55, -40, 3], [-55, -40, 3],
404
+ [40, 62, 2.5], [-40, 62, 2.5], [40, -62, 2.5], [-40, -62, 2.5],
405
+ [66, 0, 3], [-66, 0, 3], [0, 66, 3], [0, -66, 3],
406
+ ],
407
+ },
408
+ players: {
409
+ prefab: "style.lowpoly.hooded_adventurer",
410
+ max: 4,
411
+ weapon: "ember_nova",
412
+ // Level 2 after 2 gems, then a level every 6-10 s early; the exponent keeps late levels meaningful.
413
+ xp: { base: 2, growth: 2, exponent: 2.6 },
414
+ magnet: 4,
415
+ hitIframes: 0.7,
416
+ // Co-op: half of every gem goes to teammates within 20 m, so the team levels together.
417
+ share: "nearby",
418
+ // One hit in twelve lands twice as hard (the numbers show it in gold).
419
+ crit: { chance: 0.08, multiplier: 2 },
420
+ // Whoever revives a teammate burns hotter for a few seconds.
421
+ reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 },
422
+ // Bosses grow 40% per extra keeper (a squad spreads its fire; 55% made the first fight drag in co-op).
423
+ bossHealthPerPlayer: 0.4,
424
+ // Going down is short: 10 s downed (you can crawl), one free get-up a run (Space), then back in 5 s beside a teammate.
425
+ downed: { seconds: 10, crawl: 0.35, selfRevives: 1, respawn: 5 },
426
+ },
427
+ });
428
+
429
+ // Enemies. Health in hit points, speed in m/s (keepers run at 7), radius in m, damage per touch.
430
+ // Each role joins the run on its own minute (see the director), one new idea at a time.
431
+ // Fodder sometimes drops a meal (heal), a lodestar (pulls every gem) or a flare (clears the screen).
432
+ const LUCK = [{ id: "hearth_meal", chance: 0.012 }, { id: "lodestar", chance: 0.008 }, { id: "flare", chance: 0.006 }];
433
+ // The first enemies are warm embers-gone-wrong: orange against the cold floor, so you read them at a glance.
434
+ prefab("shade", { model: "style.lowpoly.ghost", appearance: { tint: "#ff8a3d", accent: "#ffd27a" }, health: 20, speed: 3.8, radius: 0.35, damage: 6, ai: "chase", drops: ["ember_small", ...LUCK] });
435
+ prefab("gnat", { model: "style.lowpoly.bee", appearance: { tint: "#ffc24a" }, health: 9, speed: 5.6, radius: 0.25, damage: 3, ai: "runner", drops: ["ember_small", ...LUCK] });
436
+ prefab("husk", { model: "style.lowpoly.mushroom_imp", health: 45, speed: 2.6, radius: 0.5, damage: 10, ai: "tank", mass: 3, drops: ["ember_mid"] });
437
+ prefab("spitter", { model: "style.lowpoly.frog", appearance: { tint: "#6f9a4e", scale: 0.4 }, health: 20, speed: 2.8, radius: 0.4, damage: 5, ai: "ranged", attack: { damage: 8, range: 10, speed: 7 }, drops: ["ember_small"] });
438
+ prefab("ram", { model: "style.lowpoly.triceratops", health: 40, speed: 3, radius: 0.45, damage: 12, ai: "charger", drops: ["ember_mid"] });
439
+ prefab("bloat", { model: "style.lowpoly.slime_pink", health: 18, speed: 3.2, radius: 0.45, damage: 8, ai: "bomber", attack: { radius: 2.5, damage: 28 }, drops: ["ember_small"] });
440
+ prefab("aegis", { model: "style.lowpoly.mech", health: 35, speed: 3, radius: 0.4, damage: 6, ai: "shielder", drops: ["ember_mid"] });
441
+ prefab("mender", { model: "style.lowpoly.blob_wizard", health: 30, speed: 3, radius: 0.4, damage: 6, ai: "healer", drops: ["ember_mid"] });
442
+ prefab("brute", { model: "style.lowpoly.orc_brute", health: 160, speed: 2.2, radius: 0.8, damage: 18, ai: "tank", mass: 6, drops: ["ember_mid", "ember_mid"] });
443
+ prefab("brood", { model: "style.lowpoly.slime_green", health: 40, speed: 3, radius: 0.5, damage: 8, ai: "chase", split: { into: "shade", count: 3 }, drops: ["ember_small"] });
444
+ prefab("caller", { model: "style.lowpoly.imp", health: 45, speed: 2.6, radius: 0.4, damage: 6, ai: "summoner", attack: { summon: "gnat", count: 2 }, drops: ["ember_mid"] });
445
+
446
+ // Bosses. The Ash Matron at 1:45, the Gloam Ram at 4:30, the Bell Warden at 10:00. Every attack is telegraphed.
447
+ // The Matron is the first real fight: a winged silhouette no mob shares, two attacks to learn
448
+ // (a slam ring around her, a fan of embers at you), about 45 s solo, and a hoard chest when she falls.
449
+ prefab("ash_matron", {
450
+ model: "style.lowpoly.dragon",
451
+ appearance: { tint: "#5d5551", accent: "#ff6a2a" },
452
+ health: 290,
453
+ speed: 2.6,
454
+ radius: 1.5,
455
+ damage: 16,
456
+ boss: { attacks: ["slam", "fan"], rank: "mini" },
457
+ drops: ["ember_large"],
458
+ });
459
+ prefab("gloam_ram", {
460
+ model: "style.lowpoly.trex",
461
+ appearance: { tint: "#5b3a8c", accent: "#c9a7ff" },
462
+ health: 1500,
463
+ speed: 2.8,
464
+ radius: 1.5,
465
+ damage: 22,
466
+ boss: { attacks: ["dash", "leap", "rain"], rank: "mini" },
467
+ drops: ["ember_large"],
468
+ });
469
+ prefab("bell_warden", {
470
+ model: "style.lowpoly.mushroom_king",
471
+ health: 14500,
472
+ speed: 2.4,
473
+ radius: 1.8,
474
+ damage: 30,
475
+ // The bell tolls: slams, rings of shards, a charge down a lane, and shades called to its side.
476
+ boss: { attacks: ["slam", "ring", "dash", "summon"], summon: "shade", enrageAt: 0.5 },
477
+ drops: ["ember_large"],
478
+ });
479
+
480
+ // The Lantern Thief: an event's runaway carrier. It flees; catch it for a chest.
481
+ prefab("lantern_thief", { model: "style.lowpoly.fox", appearance: { tint: "#ffd36b" }, health: 140, speed: 6.2, radius: 0.45, damage: 0, ai: "flee", drops: [{ id: "ember_chest", chance: 1 }] });
482
+
483
+ // Props: break them for gems and luck. Scattered over the field at the start. Stumps and campfires
484
+ // come from the same Kenney Nature Kit as the treeline, so the field reads as one place.
485
+ prefab("ember_urn", { model: "style.lowpoly.stump", health: 25, speed: 0, radius: 0.5, damage: 0, prop: true, drops: ["ember_small", { id: "hearth_meal", chance: 0.12 }, { id: "lodestar", chance: 0.04 }] });
486
+ prefab("brazier", { model: "style.lowpoly.campfire", health: 60, speed: 0, radius: 0.6, damage: 0, prop: true, drops: ["ember_mid", { id: "hearth_meal", chance: 0.25 }, { id: "ember_chest", chance: 0.1 }] });
487
+
488
+ // Special pickups. Elites always drop a chest; bosses drop a meal, a lodestar and a chest.
489
+ prefab("hearth_meal", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.25 } });
490
+ prefab("lodestar", { model: "style.lowpoly.crystal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "vacuum" } });
491
+ prefab("flare", { model: "style.lowpoly.grenade", health: 1, speed: 0, kind: "pickup", pickup: { effect: "bomb" } });
492
+ prefab("ember_chest", { model: "style.lowpoly.chest", health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 2 } });
493
+ // A boss's hoard: three picks, and a weapon you hold with its paired passive jumps to max so it evolves.
494
+ prefab("hoard_chest", { model: "style.lowpoly.chest", appearance: { tint: "#ffcf5a", scale: 1.6 }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 3, evolve: true } });
495
+
496
+ // Gems (pickups): the XP each is worth.
497
+ prefab("ember_small", { model: "style.lowpoly.gem_small", health: 1, speed: 0, kind: "pickup", value: 1 });
498
+ prefab("ember_mid", { model: "style.lowpoly.gem_mid", health: 1, speed: 0, kind: "pickup", value: 6 });
499
+ prefab("ember_large", { model: "style.lowpoly.gem_large", health: 1, speed: 0, kind: "pickup", value: 60 });
500
+
501
+ // Weapons fire by themselves. Cooldowns in seconds, ranges in metres.
502
+ // Each level is a named change the card shows; at max level, with its paired
503
+ // passive taken, a weapon evolves (the card always shows). 8 weapons + 7 evolutions = 15.
504
+ weapon("ember_nova", {
505
+ kind: "nova", damage: 12, cooldown: 1.1, range: 3.8, knockback: 2.5,
506
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { range: "+15%" }, { damage: "+25%", knockback: 3 }],
507
+ evolve: { into: "sun_bell", requires: "bellows" },
508
+ });
509
+ weapon("spark_bolt", {
510
+ kind: "bolt", damage: 14, cooldown: 0.6, range: 14, speed: 24, pierce: 1, knockback: 2,
511
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { pierce: 1 }, { count: 1 }, { damage: "+40%" }],
512
+ evolve: { into: "star_volley", requires: "twin_wick" },
513
+ });
514
+ weapon("halo_shards", {
515
+ kind: "orbit", damage: 8, cooldown: 0.1, range: 2.4, count: 2, speed: 3.6, knockback: 3,
516
+ levels: [{ count: 1 }, { damage: "+30%" }, { range: "+20%" }, { count: 1 }, { speed: "+25%" }, { damage: "+40%" }],
517
+ evolve: { into: "halo_crown", requires: "lantern_glass" },
518
+ });
519
+ weapon("cinder_lance", {
520
+ kind: "bolt", damage: 30, cooldown: 1.6, range: 22, speed: 34, pierce: 6, knockback: 4,
521
+ levels: [{ damage: "+30%" }, { pierce: 3 }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
522
+ evolve: { into: "sunspear", requires: "kindling" },
523
+ });
524
+ weapon("ash_whip", {
525
+ kind: "cone", damage: 18, cooldown: 1.1, range: 4, spread: 100, knockback: 6,
526
+ levels: [{ damage: "+30%" }, { spread: 40 }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
527
+ evolve: { into: "ember_whirl", requires: "swift_boots" },
528
+ });
529
+ weapon("arc_wire", {
530
+ kind: "chain", damage: 16, cooldown: 1.3, range: 9, count: 2, speed: 4, knockback: 1,
531
+ levels: [{ count: 1 }, { damage: "+35%" }, { count: 1 }, { cooldown: "-20%" }, { count: 2 }],
532
+ evolve: { into: "storm_wire", requires: "lodestone" },
533
+ });
534
+ weapon("hearth_ring", {
535
+ kind: "aura", damage: 5, cooldown: 0.5, range: 2.5, knockback: 1,
536
+ levels: [{ range: "+20%" }, { damage: "+40%" }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
537
+ evolve: { into: "bonfire", requires: "hearth_heart" },
538
+ });
539
+ weapon("falling_star", {
540
+ kind: "meteor", damage: 40, cooldown: 2.2, range: 12, count: 1, blast: 1.8, knockback: 6,
541
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
542
+ });
543
+
544
+ // Evolutions: about x1.7 damage and a new shape. Never offered on their own.
545
+ weapon("sun_bell", { kind: "nova", damage: 40, cooldown: 0.8, range: 9, knockback: 9 });
546
+ weapon("star_volley", { kind: "bolt", damage: 34, cooldown: 0.4, range: 16, speed: 28, count: 6, pierce: 3, knockback: 3 });
547
+ weapon("halo_crown", { kind: "orbit", damage: 20, cooldown: 0.1, range: 3.6, count: 6, speed: 4.5, knockback: 4 });
548
+ weapon("sunspear", { kind: "bolt", damage: 80, cooldown: 1, range: 28, speed: 40, count: 3, pierce: 30, knockback: 6 });
549
+ weapon("ember_whirl", { kind: "cone", damage: 45, cooldown: 0.8, range: 5.5, spread: 360, knockback: 8 });
550
+ weapon("storm_wire", { kind: "chain", damage: 34, cooldown: 0.9, range: 11, count: 9, speed: 5, knockback: 2 });
551
+ weapon("bonfire", { kind: "aura", damage: 12, cooldown: 0.4, range: 4.5, knockback: 2 });
552
+
553
+ // Passive upgrades offered at level-up next to new weapons and weapon levels (up to 8).
554
+ // Each evolution pairs with one of them.
555
+ upgrade("swift_boots", { stat: "speed", step: 0.07, maxLevel: 5 });
556
+ upgrade("hearth_heart", { stat: "maxHealth", step: 20, maxLevel: 5 });
557
+ upgrade("lodestone", { stat: "magnet", step: 0.8, maxLevel: 5 });
558
+ upgrade("kindling", { stat: "might", step: 0.1, maxLevel: 5 });
559
+ upgrade("bellows", { stat: "haste", step: 0.06, maxLevel: 5 });
560
+ upgrade("warm_tea", { stat: "regen", step: 1, maxLevel: 3 });
561
+ upgrade("lantern_glass", { stat: "area", step: 0.1, maxLevel: 5 });
562
+ upgrade("twin_wick", { stat: "amount", step: 1, maxLevel: 2 });
563
+
564
+ // The run's pacing: a quiet opening that grows into the crowd. Curves are
565
+ // [seconds, value] keyframes; spawns land just off screen, never on it.
566
+ director({
567
+ // Playtest round 1: danger by 0:30. The first shades reach you by about 0:20, a stream of
568
+ // gnats joins at 0:25, the first elite at 0:45 and the Ash Matron at 1:45.
569
+ start: 10,
570
+ ahead: 0.5,
571
+ alive: [[0, 20], [30, 55], [60, 100], [120, 140], [180, 220], [300, 650], [420, 1300], [540, 2000]],
572
+ rate: [[0, 2], [30, 4], [60, 5], [120, 6], [180, 8], [240, 14], [300, 24], [420, 60], [540, 120]],
573
+ health: [[0, 1], [60, 1.35], [120, 1.7], [180, 2], [300, 3.3], [420, 5.5], [540, 9]],
574
+ // Touches stay survivable; the telegraphed attacks are what should kill you.
575
+ damage: [[0, 1], [60, 1.15], [180, 1.5], [360, 3.2], [480, 4.6], [600, 6]],
576
+ // Late, the swarm closes in faster: kiting forever stops working after 6:00.
577
+ speed: [[0, 1], [240, 1], [360, 1.2], [480, 1.45], [600, 1.6]],
578
+ // Minute 1 is crowded, so each early gem is worth a little less: level 5 still lands near 1:00.
579
+ // Late crowds are bigger, not richer: XP per gem stays flat, so density adds danger rather than power.
580
+ xp: [[0, 0.7], [120, 0.8], [300, 0.8], [540, 0.8]],
581
+ pool: [
582
+ { prefab: "shade", weight: 10 },
583
+ { prefab: "gnat", from: 25, weight: 2, group: [6, 9] },
584
+ // A spitter's slow, telegraphed shot is the first thing that makes kiting cost something.
585
+ { prefab: "spitter", from: 35, weight: 1.5 },
586
+ { prefab: "ram", from: 70, weight: 1.2 },
587
+ { prefab: "husk", from: 90, weight: 3 },
588
+ { prefab: "bloat", from: 240, weight: 3 },
589
+ // After 4:00 the swarm brings its range and its telegraphs back in force: spit, charge lanes and
590
+ // fuse circles you must read and dodge. A perfect kite stops being enough.
591
+ { prefab: "spitter", from: 240, weight: 4 },
592
+ { prefab: "ram", from: 240, weight: 3 },
593
+ { prefab: "aegis", from: 330, weight: 1 },
594
+ { prefab: "mender", from: 330, weight: 1 },
595
+ { prefab: "brute", from: 390, weight: 1 },
596
+ { prefab: "brood", from: 390, weight: 2 },
597
+ { prefab: "caller", from: 450, weight: 1 },
598
+ ],
599
+ fade: { after: 260, weight: 0.3 },
600
+ // Each keeper past the first adds 30% more crowd: a squad spreads out, and the base curve is already sized for one keeper late.
601
+ perPlayer: 0.3,
602
+ // Bodies the cap holds back come back tougher, and worth only a little more.
603
+ crowded: { health: 1.6, damage: 1.25, xp: 1.2 },
604
+ // An elite (x6 health, gold rim) at 0:45, then every 40-55 s.
605
+ elites: { from: 45, every: [40, 55] },
606
+ });
607
+
608
+ /** The hunters: the Ash Matron at 1:45, the Gloam Ram at 4:30, the Bell Warden at ten minutes. */
609
+ const MATRON_AT = 105;
610
+ const RAM_AT = 270;
611
+ const BOSS_AT = 600;
612
+
613
+ const TIDE_SIDES = ["north", "south", "east", "west"] as const;
614
+
615
+ system("run", ({ every, after, spawnEdge, emit, respawnDead, on, end, announce, scatter, upcoming }) => {
616
+ scatter("ember_urn", 45);
617
+ scatter("brazier", 12);
618
+ // The HUD counts down to the next hunter.
619
+ upcoming("THE ASH MATRON", MATRON_AT);
620
+ after(MATRON_AT, () => upcoming("THE GLOAM RAM", RAM_AT - MATRON_AT));
621
+ after(RAM_AT, () => upcoming("THE BELL WARDEN", BOSS_AT - RAM_AT));
622
+ // Each minute survived is a wave on the HUD: anyone who bled out comes back.
623
+ every(60, () => {
624
+ emit("wave.cleared");
625
+ respawnDead();
626
+ });
627
+ after(MATRON_AT, () => {
628
+ announce("THE ASH MATRON RISES", { tone: "danger" });
629
+ spawnEdge("ash_matron", { count: 1 });
630
+ });
631
+ after(RAM_AT, () => {
632
+ announce("THE GLOAM RAM CHARGES", { tone: "danger" });
633
+ spawnEdge("gloam_ram", { count: 1 });
634
+ });
635
+ after(BOSS_AT, () => {
636
+ announce("THE BELL TOLLS", { tone: "danger" });
637
+ spawnEdge("bell_warden", { count: 1 });
638
+ });
639
+ // The first minute has teeth: the dark closes in from every side, twice, before the Matron.
640
+ // Break through the ring (dash, or burn a gap) instead of kiting forever.
641
+ for (const at of [35, 80]) {
642
+ after(at, () => {
643
+ announce("THE DARK CLOSES IN", { tone: "danger" });
644
+ spawnEdge("shade", { count: at < 60 ? 30 : 40, side: "any" });
645
+ });
646
+ }
647
+ on("miniboss.killed", () => announce("A HUNTER FALLS · TAKE THE HOARD", { tone: "reward" }));
648
+ on("boss.killed", () => end("won"));
649
+ });
650
+
651
+ // Events: one every 75 s from 4:00, never the same twice in a row, and never while a boss lives.
652
+ system("events", ({ every, after, spawnEdge, announce, random, bossAlive, xpBoost, surge, clear, emit, players }) => {
653
+ let last = -1;
654
+ after(240, () => {
655
+ const fire = () => {
656
+ if (bossAlive()) return;
657
+ let pick = Math.floor(random() * 3);
658
+ if (pick === last) pick = (pick + 1) % 3;
659
+ last = pick;
660
+ if (pick === 0) {
661
+ // The Tide: a wall of shades sweeps in from one side.
662
+ const side = TIDE_SIDES[Math.floor(random() * TIDE_SIDES.length)] ?? "north";
663
+ announce("THE TIDE RISES", { tone: "danger" });
664
+ spawnEdge("shade", { count: 220, side, spread: 80 });
665
+ surge(1.5, 20);
666
+ // Still standing when it passes: the Tide is survived.
667
+ after(30, () => emit("tide.survived", players().filter((p) => p.alive).map((p) => p.id)));
668
+ } else if (pick === 1) {
669
+ announce("EMBER HOUR", { tone: "reward" });
670
+ xpBoost(2, 30);
671
+ } else {
672
+ announce("A LANTERN THIEF FLEES", { tone: "reward" });
673
+ spawnEdge("lantern_thief", { count: 1, side: "ahead", spread: 90 });
674
+ after(15, () => {
675
+ clear("lantern_thief");
676
+ });
677
+ }
678
+ };
679
+ fire();
680
+ every(75, fire);
681
+ });
682
+ });
683
+
684
+ // Keepers: pick one before your first level. Each starts with its own weapon and a
685
+ // signature passive. New keepers unlock with achievements, never with coins.
686
+ keeper("lamplighter", { name: "Lamplighter", text: "A steady nova and a long reach for embers.", weapon: "ember_nova", passive: "lodestone" });
687
+ keeper("sparkwright", { name: "Sparkwright", text: "Bolts at range, and quicker hands.", model: "style.lowpoly.rogue", tint: "#7ad7ff", weapon: "spark_bolt", passive: "bellows", unlock: "five_minutes" });
688
+ keeper("ash_warden", { name: "Ash Warden", text: "A sweeping whip and swift boots.", model: "style.lowpoly.monarch", tint: "#ff8a4a", weapon: "ash_whip", passive: "swift_boots", unlock: "evolver" });
689
+ keeper("star_caller", { name: "Star Caller", text: "Calls stars down; a wider glass.", model: "style.lowpoly.mini_female_c", tint: "#d9a8ff", weapon: "falling_star", passive: "lantern_glass", unlock: "bell_silenced" });
690
+
691
+ // Achievements (counted in a run; they unlock keepers and pay nothing).
692
+ achievement("five_minutes", { title: "Five Minutes of Night", text: "Hold the field for 5:00.", when: "wave.cleared", n: 5 });
693
+ achievement("evolver", { title: "Evolved", text: "Evolve a weapon.", when: "player.evolved" });
694
+ achievement("bell_silenced", { title: "The Bell Is Silent", text: "Break the Bell Warden.", when: "boss.killed" });
695
+ achievement("elite_hunter", { title: "Elite Hunter", text: "Kill 5 elites in one run.", when: "elite.killed", n: 5 });
696
+
697
+ // Daily goals: the HUD shows three a day. They point at the game's systems and pay nothing.
698
+ mission("evolve", { text: "Evolve a weapon", when: "player.evolved" });
699
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
700
+ mission("tide", { text: "Survive a Tide", when: "tide.survived" });
701
+ mission("revive", { text: "Revive a keeper", when: "player.revived" });
702
+ mission("chest", { text: "Open 2 chests", when: "chest.collected", n: 2 });
703
+ mission("thief", { text: "Catch the Lantern Thief", when: "lantern_thief.killed" });
704
+ mission("matron", { text: "Fell the Ash Matron", when: "miniboss.killed" });
705
+
706
+ // End-screen titles: who led the run at what.
707
+ title("butcher", { text: "The Butcher", most: "kills" });
708
+ title("furnace", { text: "The Furnace", most: "damage" });
709
+ title("medic", { text: "Field Medic", most: "revives" });
710
+ title("untouchable", { text: "Untouchable", least: "downs" });
711
+ title("magpie", { text: "Magpie", most: "pickups" });
712
+
713
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each.
714
+ earn.rule("first_light", { when: "wave.cleared", n: 2, pay: 5, oncePer: "run" });
715
+ earn.rule("night_watch", { when: "wave.cleared", n: 7, pay: 20, oncePer: "run" });
716
+ earn.rule("bell_breaker", { when: "boss.killed", n: 1, pay: 50, oncePer: "run" });
717
+ earn.rule("swarm_breaker", { when: "enemy.killed", n: 500, pay: 5, oncePer: "run" });
718
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
719
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
720
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
721
+ earn.stake({ min: 20, max: 200, multiple: 2 });
722
+
723
+ // Store (the Kit), priced in coins (the default coin model): a revive used while
724
+ // downed, and a cosmetic keeper palette owned for good (kept in the profile).
725
+ store.sku("revive", { coins: 50 });
726
+ store.sku("skin_aurora", { coins: 300, grants: { cosmetic: 1 } });
727
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
728
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
729
+ store.sku("ember_charm", { coins: 150, grants: { gear: "kindling", loseBelow: 0.25 } });
730
+ ```
731
+
732
+ ## Validate and playtest
733
+
734
+ - Validate: the MCP tool `netforge_validate_game` builds `src/game.ts` and returns one actionable line per problem (an unknown asset id, a missing earn.stake, a bad id). The same checks throw `GameDefinitionError` when the module is imported.
735
+ - Playtest: the MCP tool `netforge_run_playtest`, or in code `engine.test.run({ ticks: 30 * 180, bots: 4, assert: [] })`. It reports kills, wave, maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"` a human-like bot plays and `report.fun` holds the fun metrics the gate scores (first threat, first level, downs before 1:00, spawns on screen and more).
736
+ - Template tests: test/harness.test.ts, test/host.test.ts, test/store.test.ts, run with `npx vitest run` in this package.
737
+ - Play locally: put `src/game.ts` and a `netnet.json` of `{"template":"survivor"}` in a folder, install `@netforge/kit-play` and `@netforge/engine`, and run `npx kit-play` (or the MCP tool `netforge_play_locally`). It opens http://127.0.0.1:4663 with play money.
738
+ - Docs: https://forge.netnet.capital/docs/templates/survivor
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # EMBERWATCH: the survivor template (NetNet game runtime, Template 1)
1
+ # Net Forge survivor template (EMBERWATCH)
2
2
 
3
3
  > A co-op survivor-like for 4 players and up to 2,000 enemies. You change
4
4
  > the game by editing src/game.ts only. The template already has waves,
package/package.json CHANGED
@@ -1,12 +1,30 @@
1
1
  {
2
2
  "name": "@netforge/template-survivor",
3
- "version": "0.1.0",
4
- "description": "EMBERWATCH: Template 1 of the NetNet game runtime, a survivor-like co-op for 4 players and 2,000 enemies. The game module, its browser client and its playtest bots.",
3
+ "version": "0.2.0",
4
+ "description": "Survivor-like co-op game template for Net Forge: 4 players, 2,000 enemies, authoritative multiplayer (EMBERWATCH).",
5
+ "keywords": [
6
+ "game-template",
7
+ "multiplayer",
8
+ "browser-game",
9
+ "html5-game",
10
+ "threejs",
11
+ "game-engine",
12
+ "typescript",
13
+ "gamedev",
14
+ "ai-agents",
15
+ "mcp",
16
+ "net-forge",
17
+ "survivor-like",
18
+ "roguelike"
19
+ ],
5
20
  "license": "MIT",
6
21
  "type": "module",
7
22
  "exports": {
8
23
  ".": "./src/manifest.ts",
9
- "./store": "./src/store.ts"
24
+ "./store": "./src/store.ts",
25
+ "./package.json": "./package.json",
26
+ "./llms.txt": "./llms.txt",
27
+ "./llms-full.txt": "./llms-full.txt"
10
28
  },
11
29
  "files": [
12
30
  "src",
@@ -15,26 +33,37 @@
15
33
  "llms.txt",
16
34
  "README.md",
17
35
  "NOTICE",
18
- "LICENSE"
36
+ "LICENSE",
37
+ "llms-full.txt",
38
+ "AGENTS.md",
39
+ "CLAUDE.md"
40
+ ],
41
+ "sideEffects": [
42
+ "./src/game.ts",
43
+ "./src/manifest.ts",
44
+ "./client/**"
19
45
  ],
20
46
  "publishConfig": {
21
47
  "access": "public"
22
48
  },
49
+ "homepage": "https://forge.netnet.capital/docs/templates/survivor",
50
+ "bugs": {
51
+ "url": "https://github.com/netnetdev/net-forge/issues"
52
+ },
23
53
  "repository": {
24
54
  "type": "git",
25
55
  "url": "git+https://github.com/netnetdev/net-forge.git",
26
56
  "directory": "packages/template-survivor"
27
57
  },
28
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-survivor#readme",
29
58
  "engines": {
30
59
  "node": ">=20"
31
60
  },
32
61
  "dependencies": {
33
62
  "nipplejs": "1.0.4",
34
63
  "three": "0.186.1",
35
- "@netforge/asset-library": "0.1.0",
36
- "@netforge/engine-sim": "0.1.0",
37
- "@netforge/engine": "0.1.0"
64
+ "@netforge/engine-sim": "0.2.0",
65
+ "@netforge/asset-library": "0.2.0",
66
+ "@netforge/engine": "0.2.0"
38
67
  },
39
68
  "devDependencies": {
40
69
  "@types/node": "22.20.1",
@@ -44,9 +73,6 @@
44
73
  "vite": "6.4.3",
45
74
  "vitest": "3.2.7"
46
75
  },
47
- "bugs": {
48
- "url": "https://github.com/netnetdev/net-forge/issues"
49
- },
50
76
  "scripts": {
51
77
  "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
52
78
  "typecheck": "tsc -p tsconfig.json",