@netforge/template-dungeon 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,74 @@
1
+ # Extending CRYPTWICK
2
+
3
+ CRYPTWICK is the co-op dungeon crawl template of the Net Forge game engine. The template already has the crypt's walls and floors, enemies that path through doors, weapons, XP shards, level-ups, bosses, co-op revive, netcode, rendering, touch controls, earn and the store. You change the game by changing declarations and the crawl system. Read `llms.txt` for every field, `llms-full.txt` for the complete worked example, and `GAME-DESIGN.md` in `@netforge/engine` for the design rules the playtest gate scores.
4
+
5
+ ## The one file to edit
6
+
7
+ - `src/game.ts` is the whole game, including the `crawl` system (room wakes, `ENCOUNTERS` by depth, `STIR_AFTER`, reliquaries). Change it in place.
8
+ - `src/names.json`, next to it, holds the title and every label players read (weapons, upgrades, earn rules, store items). Add a label there for every new id.
9
+ - Everything else (`src/manifest.ts`, `src/store.ts`, `client/`, `test/`, `scripts/`) is template machinery. Do not edit it.
10
+
11
+ ## Imports
12
+
13
+ `src/game.ts` imports only from `@netforge/engine`. The shipped game uses:
14
+
15
+ ```ts
16
+ import { achievement, dungeon, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
17
+ ```
18
+
19
+ Also available: `economy` and `metaUpgrade` (opt-in, only when the creator asks for permanent upgrades; the engine caps them at +15% a stat and +30% in all).
20
+
21
+ ## Declarations
22
+
23
+ - `dungeon({ seed, rooms, cell, size, corridor, loops })` grows the floor plan. Pass its `field` to `game({ field })`. `CRYPT.rooms`, `CRYPT.boss` and `CRYPT.roomAt(x, z)` feed the crawl.
24
+ - `game({ template: "dungeon", style, places, field, look?, players: { prefab, max, weapon, xp, magnet, hitIframes, downed, ... } })`.
25
+ - `prefab(id, { model, health, speed, radius, damage, ai, drops?, attack?, split?, mass? })` for the dead, bosses and pickups. `ai` is one of the engine's behaviours (`chase`, `runner`, `tank`, `ranged`, `bomber`, `healer`, `summoner`, `charger` and others).
26
+ - `weapon(id, { kind, damage, cooldown, range, levels?, evolve? })`, at most 15 including evolutions.
27
+ - `upgrade(id, ...)`, at most 8.
28
+ - `system(id, (ctx) => { ... })` with `every`, `after`, `onTick`, `spawn`, `spawnPickup`, `spawnEdge`, `spawnRing`, `announce`, `upcoming`, `alive`, `players`, `emit`, `on`, `respawnDead`, `end`, `onScreen`, `random`, `tick`, `time` and more (see llms.txt, Systems API).
29
+ - `keeper(id, { name, text, weapon, passive, model?, tint?, unlock? })`, `achievement(...)`, `mission(...)`, `title(...)`.
30
+ - `earn.rule(id, { when, n, pay, oncePer: "run" })`. Events: `room.cleared`, `enemy.killed`, `boss.killed`, `player.downed`, `player.revived`, `player.levelup`, `run.won`, `run.lost`, and anything a system emits.
31
+ - `earn.stake({ min, max, multiple })`, exactly one, on one top-level line.
32
+ - `store.sku(id, { coins, grants? })`.
33
+
34
+ ## Rules the build enforces
35
+
36
+ - Ids are lower_snake_case.
37
+ - Every game declares at least one `earn.rule` and exactly one `earn.stake`. `"stake"` is reserved.
38
+ - A dungeon game needs one enemy prefab, one weapon and `players.weapon` (or keepers).
39
+ - Drops must be pickup prefabs.
40
+ - A keeper is earned (an achievement unlock) or bought (a SKU), never both. The first keeper is free.
41
+ - Asset ids are library ids such as `"style.lowpoly.skeleton"`, never URLs or paths, and must exist in the asset library. Keep `game({ style })` as the creator picked it.
42
+ - Keep the SKUs the client is wired to exactly: `revive`, `skin_ossuary` and the `wick_charm` gear.
43
+
44
+ ## Validate
45
+
46
+ - With the MCP server (`npx -y @netforge/mcp`), call `netforge_validate_game` on `src/game.ts`. It returns one actionable line per problem.
47
+ - In this package, run the tests with vitest: `test/harness.test.ts`, `test/host.test.ts` and `test/store.test.ts`.
48
+
49
+ ## Playtest
50
+
51
+ - With the MCP server, call `netforge_run_playtest`.
52
+ - In code, import the game module and run the headless harness:
53
+
54
+ ```ts
55
+ import { engine } from "@netforge/engine";
56
+ import "./src/game.js";
57
+
58
+ const report = await engine.test.run({ ticks: 30 * 180, bots: 4, seed: 1, bot: "human" });
59
+ console.log(report.kills, report.wave, report.maxLevel, report.earned, report.fun);
60
+ ```
61
+
62
+ The report has kills, wave (1 + rooms cleared), maxLevel, earn by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"`, `report.fun` has the fun metrics the gate scores.
63
+
64
+ ## Play locally
65
+
66
+ In a folder with `netnet.json` (`{"template":"dungeon"}`) and `src/game.ts`, with `@netforge/kit-play` and `@netforge/engine` installed, run `npx kit-play`. It opens http://127.0.0.1:4663 with play money. The MCP tool `netforge_play_locally` gives the same steps.
67
+
68
+ ## Do not
69
+
70
+ - Do not write renderers, netcode, physics or pathing. The template has them.
71
+ - Do not reference assets by URL or file path.
72
+ - Do not import anything other than `@netforge/engine` in `src/game.ts`.
73
+ - Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
74
+ - Do not rename or reprice the SKUs the client is wired to.
package/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # CLAUDE.md
2
+
3
+ This package is CRYPTWICK, a Net Forge game template. Read AGENTS.md before changing this game. It lists the declarations this genre has, the rules the build enforces, and how to validate, playtest and play the game locally.
4
+
5
+ The five hard rules:
6
+
7
+ 1. Edit only src/game.ts (and src/names.json for the labels players read).
8
+ 2. Import only from @netforge/engine.
9
+ 3. Ids are lower_snake_case.
10
+ 4. Declare at least one earn.rule and exactly one earn.stake.
11
+ 5. Never write renderers, netcode, physics or bot AI, and never reference assets by URL or file path (use library ids such as "style.lowpoly.frog").
package/README.md CHANGED
@@ -1,55 +1,85 @@
1
- # CRYPTWICK (Template 3: co-op dungeon crawl)
1
+ # @netforge/template-dungeon
2
2
 
3
- Four wickbearers go down into a candle-lit crypt, room by room. The crypt
4
- is grown from a seed (`dungeon(...)`): ten rooms joined by corridors, the
5
- start room at the top and the Wax Abbot in the deepest one. Rooms wake as
6
- the party nears them and their dead come through the doors; a cleared room
7
- leaves a reliquary (pick an upgrade) and brings back anyone who fell. Runs
8
- are permadeath: if the whole party is down at once, the descent is over.
3
+ A co-op dungeon crawler game template for Net Forge: up to 4 players descend a procedural crypt, room by room, in permadeath runs (CRYPTWICK).
4
+
5
+ Four wickbearers go down into a candle-lit crypt. The crypt is grown from a seed with `dungeon(...)`: ten rooms joined by corridors, the start room at the top and the Wax Abbot in the deepest one. Rooms wake as the party nears them and their dead come through the doors. A cleared room leaves a reliquary (pick an upgrade) and brings back anyone who fell. If the whole party is down at once, the descent is over.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @netforge/template-dungeon
11
+ ```
12
+
13
+ You rarely import this package directly. kit play (`@netforge/kit-play`) and the Net Forge MCP server use it to build and run a game whose `src/game.ts` you write.
14
+
15
+ ## Play it locally
16
+
17
+ ```sh
18
+ mkdir -p my-game/src && cd my-game
19
+ npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-dungeon
20
+ cp node_modules/@netforge/template-dungeon/src/game.ts src/
21
+ echo '{"template":"dungeon"}' > netnet.json
22
+ npx kit-play # opens http://127.0.0.1:4663
23
+ ```
24
+
25
+ Open the Invite link (`?room=<id>`) in another tab to play together, or start with `--host 0.0.0.0` and open it on a phone on the same network. Coins are play money. Real-token earning, stakes and claims run only on the hosted Net Forge server.
26
+
27
+ ## Change the game
28
+
29
+ Edit `src/game.ts` and run `npx kit-play` again. For example, grow a different and larger crypt by changing the seed and the room count:
30
+
31
+ ```ts
32
+ const CRYPT = dungeon({ seed: 7, rooms: 14, cell: 24, size: [12, 18], corridor: 6, loops: 3 });
33
+ ```
34
+
35
+ Or make the candle burst, the starting weapon, hit harder:
36
+
37
+ ```ts
38
+ weapon("candle_burst", {
39
+ kind: "nova", damage: 16, cooldown: 1.1, range: 3.8, knockback: 2.5,
40
+ // levels and evolve as shipped
41
+ });
42
+ ```
43
+
44
+ ## How it plays on the server
45
+
46
+ - Server-authoritative at 30 Hz on the survivor machinery: auto-firing weapons, shards and level-up cards, co-op downs and revives, keepers, and bosses with telegraphed attacks. Your own wickbearer is predicted.
47
+ - The crypt's walls are boxes in the sim (`field.walls`) and its floors (`field.floors`) are the only ground. Enemies behind a wall walk round through the doors on a flow field, with a wider grid for big bodies.
48
+ - The crawl is a system in `src/game.ts` that you can read and change: room wakes, encounters by depth, off-screen spawns inside the woken room, the quiet timer that wakes the nearest room when the party waits, reliquaries and the respawn on clear.
49
+ - Earn: rooms cleared (2 and 7), the Abbot, and 60 kills by one wickbearer, once per run each. The room server is the referee.
50
+
51
+ ## Files
9
52
 
10
53
  | Path | What |
11
54
  |---|---|
12
- | `src/game.ts` | The game, as the model writes it (declarations only): the crypt's spec, the dead, weapons, the crawl system, earn, store. |
13
- | `src/manifest.ts` | Loads and compiles it; `dungeonAdapter()` for the room server and client. Room template `dungeon`. |
14
- | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive (50 coins), a cosmetic (300 coins) and the wick charm (losable gear, 150 coins), shown under the token's own symbol and logo; mock until the token is live. |
15
- | `client/` | The browser client: three.js scene, the crypt's floors and walls (`client/crypt.ts`, with an x-ray cut so a wall never hides your wickbearer and a fog of war over rooms nobody has entered), HUD, input (keys, gamepad, touch), game feel. |
16
- | `test/` | Playtest bots through `engine.test.run`, and the store. |
17
- | `llms.txt` | How to change the game (for agents). |
55
+ | `src/game.ts` | The game, as declarations only: the crypt's spec, the dead, weapons, the crawl system, earn and the store. This is the file you edit. |
56
+ | `src/manifest.ts` | Loads and compiles the game; `dungeonAdapter()` for the room host and the client. |
57
+ | `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive, a cosmetic and the wick charm (losable gear). |
58
+ | `client/` | The browser client: three.js scene, the crypt's floors and walls with an x-ray cut and fog of war, HUD, input (keys, gamepad, touch), game feel. |
59
+ | `test/` | Playtest bots through `engine.test.run`, the host test and the store test. |
60
+ | `llms.txt`, `llms-full.txt` | How to change the game, for agents. |
18
61
 
19
- ## How it plays on the server
62
+ ## Controls
63
+
64
+ WASD or arrows to move, Space to dash, 1 2 3 to pick a card. On touch, drag on the left half, use the DASH button and tap a card. The backtick key shows stats.
20
65
 
21
- - Server-authoritative at 30 Hz on the survivor machinery (auto-firing
22
- weapons, shards and level-up cards, co-op downs and revives, keepers,
23
- bosses with telegraphed attacks). Your own wickbearer is predicted.
24
- - The crypt's walls are boxes in the sim (`field.walls`): players meet them
25
- in Rapier, and enemies, bolts and spawns are resolved against them by hand
26
- like pillars. Floors (`field.floors`) are the only ground: spawns land on
27
- them, and enemies behind a wall walk round through the doors on a flow
28
- field (engine-sim `walls.ts`), with a wider grid for big bodies.
29
- - The crawl is a system in game.ts (the model can read and change it): room
30
- wakes, encounters by depth, off-screen spawns inside the woken room, the
31
- quiet timer that wakes the nearest room when the party waits, reliquaries
32
- and the respawn on clear.
33
- - Earn: room.cleared (2 and 7 rooms), the Abbot, and 60 kills by one wickbearer, once per
34
- run each, on the same pay structure as EMBERWATCH. The room server is the
35
- referee; before graduation earn is credited as points, after it the
36
- game's own coins under its own name.
37
-
38
- ## Run it
39
-
40
- This template runs on your own computer with play money through
41
- `kit play` (`@netforge/kit-play`). From the repository root:
66
+ ## For AI agents
67
+
68
+ Read `AGENTS.md` and `llms.txt` before editing. The Net Forge MCP server gives a coding agent the templates, the API and a playtest loop:
42
69
 
43
70
  ```sh
44
- pnpm install --frozen-lockfile && pnpm build
45
- mkdir -p play-dungeon/src && cp packages/template-dungeon/src/game.ts play-dungeon/src/
46
- echo '{"template":"dungeon"}' > play-dungeon/netnet.json
47
- node packages/kit-play/dist/cli.js play-dungeon # opens http://127.0.0.1:4663
71
+ npx -y @netforge/mcp
48
72
  ```
49
73
 
50
- Edit `play-dungeon/src/game.ts` and run it again to try a change. Real-token
51
- earning, stakes and claims run only on the hosted Net Forge server.
74
+ The package is `@netforge/mcp` and its bin is `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`. The engine's `GAME-DESIGN.md` (in `@netforge/engine`) has the design rules the playtest gate scores.
75
+
76
+ 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`.
77
+
78
+ ## Docs
79
+
80
+ - https://forge.netnet.capital/docs/templates/dungeon
81
+ - `llms.txt` and `llms-full.txt` in this package. The full file includes the complete `src/game.ts` and the validate and playtest steps.
82
+
83
+ ## License
52
84
 
53
- Open the Invite link in another tab or on a phone on the same network to
54
- play together (`?room=<id>`). Keys: WASD or arrows, Space dash, 1 2 3 pick.
55
- Touch: drag on the left half, DASH button, tap a card. ` shows stats.
85
+ MIT. See LICENSE and NOTICE.
package/client/main.ts CHANGED
@@ -160,7 +160,7 @@ async function main(): Promise<void> {
160
160
  const joinMs = performance.now() - joinStart;
161
161
  if (friendRoomId && conn.roomId !== friendRoomId) setTimeout(() => hud.toast("That room was full, started a new one"), 500);
162
162
  if (conn.welcome.version !== GAME_VERSION) setTimeout(() => hud.toast("A new version is live. Reload to update."), 800);
163
- 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") ?? "")}` : ""}`);
163
+ 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") ?? "")}` : ""}`);
164
164
 
165
165
  // The engine's feel kit: hit-stop clock, trauma shake, flash, knockback, numbers, sound, rumble.
166
166
  const feelParam = params.get("feel");
@@ -233,7 +233,7 @@ async function main(): Promise<void> {
233
233
  revive: () => void paidRevive(),
234
234
  getUp: () => input.getUp(),
235
235
  invite: () => {
236
- const url = `${location.origin}${location.pathname}?room=${conn.roomId}${place !== PLACE ? `&place=${encodeURIComponent(place)}` : ""}${params.has("server") ? `&server=${encodeURIComponent(endpoint)}` : ""}`;
236
+ 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") ?? "")}` : ""}`;
237
237
  void navigator.clipboard?.writeText(url).then(
238
238
  () => hud.toast("Invite link copied"),
239
239
  () => hud.toast(url),
package/llms-full.txt ADDED
@@ -0,0 +1,790 @@
1
+ # Net Forge co-op dungeon template (CRYPTWICK)
2
+
3
+ > A co-op dungeon crawl for up to 4 players: a procedural crypt of rooms
4
+ > from a seed, rooms that wake as the party nears them, loot in every
5
+ > cleared room, a mini-boss on the way down and a boss in the deepest
6
+ > room. Runs are permadeath: a wipe ends the descent. You change the game
7
+ > by editing src/game.ts only. The template already has the crypt's walls
8
+ > and floors, enemies that path through doors, weapons, XP shards,
9
+ > level-ups, bosses, co-op revive, netcode, rendering, touch controls, earn
10
+ > and the store. Do not write renderers, netcode or physics.
11
+
12
+ ## Files
13
+
14
+ - src/game.ts: THE GAME. Declarations against @netforge/engine:
15
+ dungeon(), game(), prefab(), weapon(), upgrade(), system(), earn.rule(),
16
+ earn.stake(), store.sku(), keeper(), achievement(), mission(), title().
17
+ - packages/engine/GAME-DESIGN.md: the design rules the playtest gate scores
18
+ (a readable opening, off-screen spawns, i-frames, telegraphs, fast levels).
19
+ - client/names.ts: display names for weapon, upgrade and earn ids (labels).
20
+ - 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.
21
+ - Everything else is template machinery.
22
+
23
+ ## Rules the build enforces
24
+
25
+ - Asset ids look like "style.lowpoly.skeleton", never URLs or paths, and must be in the asset library.
26
+ - 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.
27
+ - game({ template: "dungeon" }) and game({ field: CRYPT.field }): the crypt comes from dungeon(...).
28
+ - Ids are lower_snake_case.
29
+ - Every game declares at least one earn.rule (mandatory earn) and one earn.stake (the buy-in run).
30
+ - A dungeon game needs one enemy prefab, one weapon and players.weapon (or keepers).
31
+ - Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
32
+ - Run `pnpm --filter @netforge/template-dungeon test` (playtest bots).
33
+
34
+ ## Units
35
+
36
+ Seconds, metres, metres per second, hit points. Tick rate 30 Hz.
37
+ Wickbearers run at 7 m/s; keep chasers at 3-4 m/s and fast ones under 6.
38
+
39
+ ## The crypt: dungeon(...)
40
+
41
+ const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
42
+ game({ template: "dungeon", ..., field: CRYPT.field });
43
+
44
+ - seed: any whole number; a different seed is a different floor plan.
45
+ - rooms: 3 to 20 (the start room and the boss room included). About 10
46
+ makes a 6 to 10 minute descent.
47
+ - cell: room spacing in metres (16 to 32); size: [smallest, largest] room
48
+ side in metres (at most cell - 4); corridor: corridor width in metres
49
+ (6 lets a boss through; 4 is tight); loops: extra doors that make loops.
50
+ - The start room is centred on [0, 0], where the party spawns. The deepest
51
+ room (most doors from the start) is CRYPT.boss.
52
+ - CRYPT.rooms: each room's centre (x, z), half sizes (hx, hz), depth (doors
53
+ from the start) and links (the rooms it has a corridor to).
54
+ CRYPT.roomAt(x, z) is the room holding a point, or -1 in a corridor.
55
+ - The same spec gives the same crypt on the room server, in every client
56
+ and in the playtest; nothing is downloaded.
57
+ - Walls stop players, enemies and bolts. Enemies behind a wall walk round
58
+ through the doors (a flow field), big bodies included.
59
+
60
+ ## The crawl (the "crawl" system in game.ts)
61
+
62
+ The crawl is a system you can read and change, not hidden machinery:
63
+
64
+ - Where a wickbearer stands wakes the room (if it still sleeps) and, once
65
+ that room is cleared, the rooms next to it. Two rooms fight at once at
66
+ most.
67
+ - A woken room's dead come in inside that room, off every screen (the
68
+ system tries points in the room until onScreen(x, z) is false, and after
69
+ 3 s takes any point), then come for the party through the doors.
70
+ - ENCOUNTERS lists what a room holds by its depth: [prefab, count] for one
71
+ player; each extra player adds 35%. Every third room adds an elite; the
72
+ first room three doors down brings the Marrow Knight (a mini-boss).
73
+ - When nothing of DEAD is left standing, the room is cleared: a reliquary
74
+ (a chest: pick an upgrade) appears at its centre, room.cleared fires (earn
75
+ rules count it; the HUD shows ROOM n OF N), and anyone who fell comes
76
+ back (respawnDead).
77
+ - Quiet for STIR_AFTER seconds, the nearest sleeping room stirs by itself,
78
+ so a party that waits is still found. A fight that drags past 75 s with 3
79
+ or fewer left ends: the stragglers slink back into the dark.
80
+ - The deepest room holds the boss; boss.killed ends the run won.
81
+
82
+ ## Permadeath
83
+
84
+ players: { downed: { seconds: 12, crawl: 0.35, selfRevives: 0, respawn: 0 } }
85
+ A fallen wickbearer bleeds for 12 s and can crawl to a teammate, who
86
+ revives by standing close. There is no free get-up, and once out they stay
87
+ out until the party clears a room. If everyone is down at once, the run is
88
+ lost and the next descent starts from the top with nothing carried down
89
+ (the store's revive is the one way back up). Softer: selfRevives: 1, or a
90
+ respawn time in seconds.
91
+
92
+ ## Common changes
93
+
94
+ ### Make it harder or easier
95
+ Change ENCOUNTERS (counts, or tougher prefabs at shallower depths), the
96
+ boss's health, STIR_AFTER (a shorter quiet), or the per-player 35%.
97
+ Easier also: players.hitIframes (0.7 s), players.magnet (4 m), a revive
98
+ (selfRevives: 1), a longer starting weapon range.
99
+
100
+ ### A bigger or smaller crypt
101
+ dungeon({ seed, rooms: 14, ... }): more rooms is a longer descent (about 40
102
+ s a room). size: [16, 22] with cell: 28 gives bigger rooms; loops: 0 is a
103
+ pure tree, every dead end a dead end.
104
+
105
+ ### Add an enemy
106
+ prefab("ghoul", { model: "style.lowpoly.zombie", health: 30, speed: 3.2, radius: 0.4, damage: 8, ai: "chase", drops: ["wick_small"] });
107
+ then put it in ENCOUNTERS at the depth it should start appearing, and in DEAD
108
+ (so a room with ghouls left is not cleared):
109
+ [["ghoul", 6], ["bone_rattler", 6]],
110
+ ai is the enemy's role. Every big hit is telegraphed on the ground first:
111
+ - "chase" (default), "tank" (a chaser; give it mass), "idle"/"none" (stands still)
112
+ - "runner": aims where you will be. Fast, fragile.
113
+ - "charger": stops inside 6 m, shows a lane 0.55 s, then charges along it.
114
+ - "ranged": keeps its distance, shows an aim line, fires a slow dodgeable shot
115
+ (only with a clear line: walls and pillars are cover).
116
+ - "bomber": walks up, arms a 1 s fuse circle, explodes (hurts enemies too).
117
+ - "shielder" / "healer": every few seconds shields or heals nearby allies.
118
+ - "summoner": keeps away and calls adds: attack: { summon: "gnat", count: 2 }.
119
+ - "flee", "wander".
120
+ Tune a role with attack: { damage, range, windup, cooldown, speed, radius, summon, count }:
121
+ prefab("bone_archer", { model: "style.lowpoly.skeleton", health: 24, speed: 2.8, ai: "ranged", attack: { damage: 9, range: 10, speed: 7 }, drops: ["wick_small"] });
122
+ A splitter bursts into smaller enemies on death:
123
+ prefab("tomb_slime", { model: "style.lowpoly.slime_horned", health: 44, speed: 2.8, split: { into: "crypt_bat", count: 3 }, drops: ["wick_small"] });
124
+
125
+ ### Add elites
126
+ An elite is x6 health, x1.5 damage, x5 XP and a gold rim, and drops a chest.
127
+ The crawl adds one every third room (add(prefab, 1, true) in wake); change
128
+ `wakes % 3` for more or fewer. From any system: spawn("grave_shambler", [x, 0.5, z], { elite: true }).
129
+
130
+ ### Add a weapon
131
+ weapon("frost_ring", {
132
+ kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
133
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { knockback: 3 }],
134
+ });
135
+ kinds:
136
+ - "nova": a burst in range; "aura": a field that burns what is inside, every cooldown
137
+ - "bolt": count projectiles at the nearest enemy (speed, pierce)
138
+ - "orbit": count blades at range metres, speed rad/s
139
+ - "cone": a sweep of spread degrees where the keeper faces (360: all around)
140
+ - "chain": strikes the nearest, then jumps count times, each within speed m
141
+ - "meteor": count circles of blast m land on enemies in range, 0.6 s after showing
142
+ - "boomerang": flies out range m and back, hitting everything both ways
143
+ levels: what each level changes from the one before (5-8 levels). A number
144
+ adds (count: 1, pierce: 2, cooldown: -0.1 s), a percent scales ("+40%").
145
+ Cards show these changes ("+1 projectile, -20% cooldown"). It is offered at
146
+ level-up automatically. Add a label in client/names.ts.
147
+
148
+ ### Add an evolution
149
+ weapon("frost_ring", { ..., evolve: { into: "blizzard", requires: "iron_skin" } });
150
+ weapon("blizzard", { kind: "nova", damage: 20, cooldown: 0.6, range: 9, knockback: 12 });
151
+ At max level, with the passive "iron_skin" taken, the "blizzard" card always
152
+ shows; it replaces frost_ring in its slot. Evolutions are never offered on
153
+ their own. Give them about x1.7 damage and a new shape (360 degrees, double
154
+ radius, more count). Pair each base weapon with a different passive.
155
+
156
+ ### Add a passive
157
+ upgrade("iron_skin", { stat: "armor", step: 1, maxLevel: 4 });
158
+ stats: speed, maxHealth, magnet, might, haste, regen, armor, area (bigger
159
+ novas, auras, cones, meteors and orbits), amount (more projectiles, blades,
160
+ jumps and meteors). Up to 8. maxLevel caps how often it is offered.
161
+
162
+ ### Add pickups and loot
163
+ prefab("tallow", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
164
+ A boss's hoard: pickup: { effect: "chest", amount: 3, evolve: true }. Bosses
165
+ drop it instead of the plain chest. If the player holds a weapon and already
166
+ has its paired passive, that weapon jumps to max level, so its evolution is
167
+ on the cards.
168
+ effects: heal (amount: fraction of max health, or hit points above 1),
169
+ vacuum (every gem on the floor comes to you), bomb (clears the screen's
170
+ non-boss enemies), chest (amount card picks; a ready evolution shows first).
171
+ Drop them with a chance: drops: ["wick_small", { id: "tallow", chance: 0.03 }].
172
+ Elites always drop the game's chest; bosses drop a heal, a vacuum and a chest.
173
+ Above 300 gems on the floor, new gems merge into the nearest one.
174
+
175
+ ### Add props (breakables)
176
+ prefab("crypt_urn", { model: "style.lowpoly.column_broken", health: 25, speed: 0, radius: 0.5, prop: true, drops: ["wick_mid", { id: "tallow", chance: 0.3 }] });
177
+ then scatter("crypt_urn", 36) from a system (props land on the floors only). Props never move, never hurt
178
+ and never count as enemies.
179
+
180
+ ### Add events and moments
181
+ In the crawl's wake(room) or clearActive(), or a system of your own:
182
+ on("room.cleared", () => { if (random() < 0.2) { announce("A SHRINE GLOWS", { tone: "reward" }); xpBoost(2, 20); } });
183
+ announce tones: info, danger, reward. Off-screen bosses get an edge arrow
184
+ automatically. Game events you can key on (and earn rules too):
185
+ room.cleared, miniboss.killed, boss.killed, player.evolved, heal.collected,
186
+ vacuum.collected, bomb.collected, chest.collected. clear("bone_rattler")
187
+ removes a prefab at once (no kills or drops).
188
+
189
+ ### Cards
190
+ Weighted: an owned weapon's next level 8, a new weapon 10 (while a slot is
191
+ free), a passive 4 (7 once weapon slots are full); a ready evolution always
192
+ shows. players.rerolls (default 1) rerolls per run (R); Skip (X) trades the
193
+ cards for a small heal. When everything is maxed, a level heals and gives
194
+ teammates a little XP.
195
+
196
+ ### Add a boss
197
+ prefab("ossuary_queen", {
198
+ model: "style.lowpoly.trex", appearance: { tint: "#c2410c", scale: 1.2 }, health: 3000, speed: 2.2, radius: 1.5, damage: 30,
199
+ boss: { attacks: ["slam", "ring", "dash", "summon"], summon: "bone_rattler", enrageAt: 0.5 },
200
+ drops: ["wick_large"],
201
+ });
202
+ In the crawl, the boss room's wake adds it: add("ossuary_queen", 1). The
203
+ crawl already ends the run on boss.killed.
204
+ Keep a boss's radius at 1.5 or under with 6 m corridors (radius 1 with 4 m
205
+ ones): it walks the corridors to reach a party that will not come to it.
206
+ Attacks (all telegraphed): slam (circle around it), dash (11 m lane), leap
207
+ (lands on a circle at you), rain (circles near players), ring (14 shots
208
+ outward), fan (7 shots at you), summon (adds). It switches near and far
209
+ modes every 4-9 s; below enrageAt health it is faster and throws more.
210
+ A mini-boss: boss: { attacks: [...], rank: "mini" } raises "miniboss.killed"
211
+ (not "boss.killed", so it never ends the run). Boss health scales with
212
+ players present (players.bossHealthPerPlayer, 0.55); every boss gets a HUD
213
+ bar and drops a burst of gems worth 1-2 levels.
214
+
215
+ ### Change pacing
216
+ A room takes about 30 to 45 s to wake and clear; STIR_AFTER (18 s) is the
217
+ longest quiet. The XP curve is players.xp { base, growth, exponent }: XP to
218
+ go from L to L+1 is base + growth x (L - 1)^exponent. Aim for level 2
219
+ within 10 s and level 5 by the end of the first minute; reliquaries add a
220
+ pick per cleared room.
221
+
222
+ ### Add an earn rule
223
+ earn.rule("five_rooms", { when: "room.cleared", n: 5, pay: 10, oncePer: "run" });
224
+ Events: room.cleared (the crawl emits it), enemy.killed (credits the killer),
225
+ boss.killed (everyone present), player.downed, player.revived (credits the
226
+ reviver), player.levelup, run.won, run.lost, and anything you emit().
227
+ Each rule pays once per run per player, in coins; the room server is the referee.
228
+
229
+ ### The buy-in run (required)
230
+ earn.stake({ min: 20, max: 200, multiple: 2 });
231
+ Every game declares one, on one top-level line; a game without it does not
232
+ build. A player stakes coins at the start of a run (the Kit spends them as
233
+ coins.spend(amount, "stake"), then the client calls conn.stake()). The room
234
+ fixes the stake when the next run starts. A staked run pays its normal earn
235
+ plus a share of stake x multiple: the share is the part of the run's most
236
+ earn the player makes. What it does not win back stays in the reward pool.
237
+ multiple is 1 to 2. "stake" is reserved: never a SKU or meta upgrade id.
238
+
239
+ ### Losable gear (optional)
240
+ store.sku("wick_charm", { coins: 150, grants: { gear: "tallow_oil", loseBelow: 0.25 } });
241
+ Gear is owned in the player's profile and sells for coins only. It brings one
242
+ level of its declared upgrade into every staked run, and is lost when that run
243
+ makes less than loseBelow of its most earn. Its coins stay in the reward pool.
244
+
245
+ ### Coins: earn and the store
246
+
247
+ Every game has coins: 1 coin = 1 of the game's own token, and players see
248
+ them under the token's own name, symbol and logo (never "coins" or "tokens"
249
+ as a label). Earn rules pay coins (`pay` is whole coins). The recommended
250
+ coin model (the default, no declaration) sells cosmetics, unlocks and
251
+ in-run consumables, never permanent stat power:
252
+
253
+ store.sku("revive", { coins: 50 }); // in-run: used while downed
254
+ store.sku("skin_ossuary", { coins: 300, grants: { cosmetic: 1 } }); // a look, owned for good
255
+
256
+ Keep them and the wick_charm gear exactly: the client is wired to them (price, look id and gear). Owned
257
+ looks and unlocks are saved on the room server in the player's profile. Players spend
258
+ earned coins first (instantly, no wallet prompt), then their wallet. Before
259
+ the game launches coins are play money; the Kit switches by itself, so
260
+ nothing in game.ts depends on it. Price in coins against what a run pays
261
+ (the four rules here pay up to 80 a run): a revive is under a run, a
262
+ cosmetic a few runs.
263
+
264
+ ### Sell a keeper unlock (a character, a starting loadout, a sidegrade)
265
+ keeper("tinker", { name: "Tinker", text: "Darts and a wide glass.", weapon: "wax_dart", passive: "stained_glass" }); // no unlock
266
+ store.sku("unlock_tinker", { coins: 400, grants: { keeper: "tinker" } });
267
+ A keeper is earned (unlock: an achievement) or bought (a SKU), never both:
268
+ the build refuses a sold keeper that also declares an unlock. Never sell
269
+ the first keeper (everyone's free default). An unlock is a different way to
270
+ play, never more power. Before launch coins are play money and buy only
271
+ in-run items; looks and unlocks open at launch.
272
+
273
+ ### Cosmetics only ("no pay to win")
274
+ economy({ store: "cosmetics" });
275
+ Then every SKU must be a look (grants: { cosmetic: n }); drop revive.
276
+
277
+ ### Permanent upgrades for coins (opt-in meta upgrades)
278
+ economy({ metaUpgrades: true });
279
+ metaUpgrade("hearth", { stat: "maxHealth", step: 0.05, levels: 3, cost: 120, growth: 2 }); // 120, 240, 480 coins
280
+ metaUpgrade("stride", { stat: "speed", step: 0.03, levels: 3, cost: 100, growth: 1.8 });
281
+ Only when the creator asks for permanent upgrades. Stats: speed, maxHealth,
282
+ magnet, might, haste, area. The engine refuses more than +15% to one stat
283
+ or +30% in all, more than 5 levels, or growth under 1.5, and applies a
284
+ player's levels at the start of every run. The store shows each level.
285
+
286
+ ### Add a keeper (a playable character)
287
+ keeper("vesper", { name: "Vesper", text: "Arcs that leap between the dead.", model: "style.lowpoly.mini_female_c", tint: "#7ad7ff", weapon: "vesper_arc", passive: "bellows", unlock: "knight_felled" });
288
+ A keeper starts every run with its weapon and its passive at level 1;
289
+ players pick one before their first level and on the end screen. At most
290
+ 15; the first declared keeper is everyone's default, and at least one
291
+ keeper has no unlock (declare a free one first). Keepers unlock with
292
+ achievements, or with coins through a SKU with grants: { keeper }. The room
293
+ server checks every pick against the player's saved unlocks.
294
+ With keepers declared, players.weapon is optional.
295
+
296
+ ### Add achievements, daily goals and end-screen titles
297
+ achievement("evolver", { title: "Evolved", text: "Evolve a weapon.", when: "player.evolved" });
298
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
299
+ title("butcher", { text: "The Butcher", most: "kills" });
300
+ Achievements and missions count a game event per player in one run (n
301
+ times) and pay nothing (coins come only from earn rules). The room server
302
+ grants them from the run and saves them per player (wallet, or guest until
303
+ sign-in), so they follow the player to every device. The HUD shows
304
+ three missions a day. Titles rank players at the end of a run by most or
305
+ least of: kills, damage, revives, downs, level, pickups. Events to count:
306
+ anything you emit, "<prefab>.killed" ("marrow_knight.killed"),
307
+ "elite.killed", "player.evolved", "player.revived", "chest.collected",
308
+ "miniboss.killed", "boss.killed", "room.cleared".
309
+
310
+ ### Co-op that feels shared
311
+ players: { share: "nearby" } // default: half of each gem to teammates within 20 m
312
+ players: { share: "all" } // or "collector", or { mode, fraction, radius }
313
+ players: { reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 } } // the reviver burns hotter
314
+ players: { downed: { seconds: 12, crawl: 0.35, selfRevives: 0, respawn: 0 } } // permadeath (this game): back only when a room is cleared
315
+ players: { lateJoinBehind: 2 } // a late joiner starts 2 levels under the team, cards waiting
316
+ players: { crit: { chance: 0.08, multiplier: 2 } } // crits show as gold numbers
317
+ The end screen votes "Play again" (everyone ready starts the next run; it
318
+ starts by itself after 10 s) and shows each keeper's build, damage per
319
+ weapon, what downed them, coins and titles.
320
+
321
+ ### Make co-op 8 players
322
+ players.max: 8. The hosted Net Forge room server seats at most 8 in a
323
+ co-op room (the dungeon room seats 4 today), so 8 is the most a game can
324
+ ask for.
325
+
326
+ ## Models and appearances (the asset library)
327
+
328
+ Every model is a real CC0 model from the asset library, by id. Enemies are
329
+ drawn about 3.2 hit radii tall, keepers 1.8 m; animated ones walk, run and
330
+ die by themselves. An appearance dresses a model into your own original:
331
+ appearance: { tint: "#rrggbb", accent: "#rrggbb", scale: 0.4 to 3, parts: up to 4 }.
332
+
333
+ ### Make the dead look like slimes
334
+ prefab("bone_rattler", { model: "style.lowpoly.slime_green", ... });
335
+ prefab("grave_shambler", { model: "style.lowpoly.slime_spiky", ... });
336
+ prefab("ossuary_brute", { model: "style.lowpoly.slime_pink", appearance: { scale: 1.3 }, ... });
337
+
338
+ ### A goblin warren instead of a crypt
339
+ prefab("bone_rattler", { model: "style.lowpoly.goblin", ... }); keep the ids or rename
340
+ them everywhere (ENCOUNTERS and DEAD too), and retell it in names.json.
341
+
342
+ ### A hero described by the creator
343
+ players: { prefab: "style.lowpoly.worker", appearance: { tint: "#2e7d32", accent: "#3b2a1a", parts: ["mustache", "hat_cap"] }, ... }
344
+ Name it something new in names.json. A creature the library lacks: the
345
+ nearest base plus parts, e.g. model "style.lowpoly.shiba" with
346
+ appearance { tint: "#5ec8ff", parts: ["antenna", "tail_bolt"] }.
347
+
348
+ ## Look and feel (engine render stack and feel kit)
349
+
350
+ How the game looks and how hits feel are declarations too. They are
351
+ presentation only (never the simulation), so tune them freely. The full
352
+ reference is packages/engine/llms.txt.
353
+
354
+ ### Change the look
355
+ game({ ..., look: { preset: "dusk", bloom: 1.4, fog: 0.3, tint: "#ffd9a0" } });
356
+ presets: "night" (default), "dusk", "day", "studio". Also shadows (off here unless
357
+ look.shadows is true), outline,
358
+ exposure, saturation, contrast, vignette.
359
+
360
+ ### Make a death or a hit feel bigger
361
+ prefab("brute", { ..., feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
362
+ Without feel, big enemies (bosses, radius 0.5 m or more) burst, scorch and
363
+ shake, and small ones burst, splat and crunch.
364
+
365
+ ### Make a weapon's impacts feel different
366
+ weapon("frost_ring", { ..., feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
367
+ Novas use it when they go off; bolts and lances when they hit.
368
+
369
+ ### Heavier or lighter knockback
370
+ weapon knockback (m/s) pushes enemies, divided by their mass. Knocked
371
+ enemies stagger, slide, bounce off walls and pillars and shove the
372
+ enemies behind them. Bolts stop at walls and pillars.
373
+
374
+ ## Systems API
375
+
376
+ every(seconds, n => ...), after(seconds, fn), onTick(fn), spawn(prefab, [x,y,z], { elite? }),
377
+ spawnPickup(prefab, [x,y,z]), announce(text, { tone }), bossAlive(),
378
+ pauseDirector(seconds), surge(multiplier, seconds), xpBoost(multiplier, seconds),
379
+ scatter(prefab, count), clear(prefab), upcoming(label, seconds) (a HUD countdown:
380
+ upcoming("THE BELL WARDEN", 240)),
381
+ spawnEdge(prefab, { count, around?, side?, spread?, scale? }) (just off screen;
382
+ side: "any" | "ahead" | "behind" | "north" | "south" | "east" | "west"),
383
+ spawnRing(prefab, { count, radius, around? }), alive(prefab?), players(),
384
+ emit(event), on(event, players => ...), respawnDead(), end("won" | "lost"),
385
+ onScreen(x, z) (true when a player who is up can see that ground point),
386
+ random(), tick(), time().
387
+
388
+ ## Test
389
+
390
+ engine.test.run({ ticks: 30 * 180, bots: 4, assert: [...] }) plays the game
391
+ on the server sim with bots and reports kills, wave, maxLevel, earned by
392
+ rule, deaths, runs won and lost, softlock and ms per tick (wave is
393
+ 1 + rooms cleared). With
394
+ bot: "human" a human-like bot plays (200 ms reactions, kiting, a second per
395
+ card) and report.fun has the fun metrics: first threat, first level,
396
+ level at 1/3/6/9 minutes, downs before 1:00, enemies on screen at 0:30,
397
+ touches in minute 1, spawns on screen, survival. Net Forge's gate scores
398
+ them; `pnpm --filter @netforge/template-dungeon fun` prints the table.
399
+
400
+ ## Worked example: src/game.ts
401
+
402
+ This is the template's complete src/game.ts as it ships. A new game on this
403
+ template starts from this file and changes declarations in place.
404
+
405
+ ```ts
406
+ /**
407
+ * CRYPTWICK: the co-op dungeon crawl template.
408
+ *
409
+ * Four wickbearers go down into a candle-lit crypt, room by room. Rooms
410
+ * wake as you near them: their dead stir and come for your light through
411
+ * the doors. Clear a room for its reliquary (pick an upgrade) and to bring
412
+ * back anyone who fell. Down the deepest stair the Wax Abbot waits. A run
413
+ * is one descent: if the whole party falls, it is over, and the next one
414
+ * starts from the top with nothing carried down.
415
+ *
416
+ * This file is the whole game as the model writes it: declarations only,
417
+ * against the typed API in @netforge/engine. The crypt's floor plan is
418
+ * dungeon(...) from a seed; the crawl (which rooms wake, what they hold,
419
+ * when the party is safe) is the "crawl" system below. Everything else
420
+ * (sim, pathing, netcode, renderer, referee) is the template's machinery.
421
+ * Design rules: packages/engine/GAME-DESIGN.md.
422
+ */
423
+ import { achievement, dungeon, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
424
+
425
+ // The crypt: ten rooms on a 24 m grid, grown from the seed. Change the seed for a new floor plan.
426
+ const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
427
+
428
+ export default game({
429
+ template: "dungeon",
430
+ style: "lowpoly",
431
+ places: { crypt: { chunks: "places/crypt", spawn: [0, 1, 0] } },
432
+ field: CRYPT.field,
433
+ look: { preset: "night", bloom: 1.2, fog: 0.5, vignette: 0.5, tint: "#ffd9a0" },
434
+ players: {
435
+ prefab: "style.lowpoly.rogue",
436
+ max: 4,
437
+ weapon: "candle_burst",
438
+ xp: { base: 2, growth: 2, exponent: 2.4 },
439
+ magnet: 4,
440
+ hitIframes: 0.7,
441
+ share: "nearby",
442
+ crit: { chance: 0.08, multiplier: 2 },
443
+ reviveBuff: { seconds: 5, damage: 1.3, speed: 1.15 },
444
+ bossHealthPerPlayer: 0.45,
445
+ // Permadeath runs: a fallen wickbearer bleeds for 12 s (crawl to a friend), with no free get-up.
446
+ // Out, they stay out until the party clears a room. If everyone falls, the descent is over.
447
+ downed: { seconds: 12, crawl: 0.35, selfRevives: 0, respawn: 0 },
448
+ },
449
+ });
450
+
451
+ // The crypt's dead. Health in hit points, speed in m/s (wickbearers run at 7), damage per touch.
452
+ // Fodder sometimes leaves tallow (heal), a lure stone (pulls every shard) or a chapel bell (clears the room).
453
+ const LUCK = [{ id: "tallow", chance: 0.03 }, { id: "lure_stone", chance: 0.012 }, { id: "chapel_bell", chance: 0.01 }];
454
+ prefab("bone_rattler", { model: "style.lowpoly.skeleton_grunt", health: 22, speed: 3.6, radius: 0.4, damage: 7, ai: "chase", drops: ["wick_small", ...LUCK] });
455
+ prefab("crypt_bat", { model: "style.lowpoly.bat", appearance: { tint: "#6a5a8c" }, health: 9, speed: 5.4, radius: 0.3, damage: 3, ai: "runner", drops: ["wick_small", ...LUCK] });
456
+ prefab("grave_shambler", { model: "style.lowpoly.zombie_grunt", health: 55, speed: 2.4, radius: 0.5, damage: 11, ai: "tank", mass: 3, drops: ["wick_mid"] });
457
+ prefab("bone_archer", { model: "style.lowpoly.skeleton", appearance: { tint: "#d8cfb8" }, health: 24, speed: 2.8, radius: 0.4, damage: 5, ai: "ranged", attack: { damage: 9, range: 10, speed: 7 }, drops: ["wick_small"] });
458
+ prefab("wax_wisp", { model: "style.lowpoly.ghost_skull", appearance: { tint: "#ffcf7a" }, health: 18, speed: 3.4, radius: 0.4, damage: 8, ai: "bomber", attack: { radius: 2.5, damage: 26 }, drops: ["wick_small"] });
459
+ prefab("tomb_slime", { model: "style.lowpoly.slime_horned", health: 44, speed: 2.8, radius: 0.5, damage: 8, ai: "chase", split: { into: "crypt_bat", count: 3 }, drops: ["wick_small"] });
460
+ prefab("hex_imp", { model: "style.lowpoly.imp", health: 45, speed: 2.6, radius: 0.4, damage: 6, ai: "summoner", attack: { summon: "crypt_bat", count: 2 }, drops: ["wick_mid"] });
461
+ prefab("candle_mender", { model: "style.lowpoly.blob_wizard", health: 30, speed: 3, radius: 0.4, damage: 6, ai: "healer", drops: ["wick_mid"] });
462
+ prefab("ossuary_brute", { model: "style.lowpoly.orc_brute", appearance: { tint: "#8a8f7a" }, health: 170, speed: 2.2, radius: 0.8, damage: 18, ai: "tank", mass: 6, drops: ["wick_mid", "wick_mid"] });
463
+ prefab("grave_ram", { model: "style.lowpoly.demon_blue", health: 60, speed: 3, radius: 0.5, damage: 12, ai: "charger", drops: ["wick_mid"] });
464
+
465
+ // The Marrow Knight guards the middle of the crypt: a mini-boss with two telegraphed attacks and a hoard.
466
+ prefab("marrow_knight", {
467
+ model: "style.lowpoly.monarch",
468
+ appearance: { tint: "#cfc6b0", accent: "#ff9a3d", scale: 1.5 },
469
+ health: 420,
470
+ speed: 2.6,
471
+ radius: 1.2,
472
+ damage: 16,
473
+ boss: { attacks: ["slam", "dash"], rank: "mini" },
474
+ drops: ["wick_large"],
475
+ });
476
+ // The Wax Abbot waits in the deepest room: slams, rings of wax, a charge, and the dead called to his side.
477
+ prefab("wax_abbot", {
478
+ model: "style.lowpoly.demon",
479
+ appearance: { tint: "#efe2c4", accent: "#ff8a3d" },
480
+ health: 2800,
481
+ speed: 2.4,
482
+ radius: 1.5,
483
+ damage: 26,
484
+ boss: { attacks: ["slam", "ring", "dash", "summon"], summon: "bone_rattler", enrageAt: 0.5 },
485
+ drops: ["wick_large"],
486
+ });
487
+
488
+ // Props: broken columns and bone heaps to break for shards and luck, scattered over the floors at the start.
489
+ prefab("crypt_urn", { model: "style.lowpoly.column_broken", appearance: { tint: "#8f8578" }, health: 25, speed: 0, radius: 0.5, damage: 0, prop: true, drops: ["wick_small", { id: "tallow", chance: 0.15 }, { id: "lure_stone", chance: 0.04 }] });
490
+ prefab("bone_pile", { model: "style.lowpoly.rock_small", appearance: { tint: "#cdbf9e" }, health: 50, speed: 0, radius: 0.6, damage: 0, prop: true, drops: ["wick_mid", { id: "tallow", chance: 0.25 }, { id: "reliquary", chance: 0.08 }] });
491
+
492
+ // Special pickups. A cleared room leaves a reliquary at its heart; bosses leave the hoard.
493
+ prefab("tallow", { model: "style.lowpoly.cell_heal", appearance: { tint: "#ffe2a8" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
494
+ prefab("lure_stone", { model: "style.lowpoly.crystal", health: 1, speed: 0, kind: "pickup", pickup: { effect: "vacuum" } });
495
+ prefab("chapel_bell", { model: "style.lowpoly.grenade", health: 1, speed: 0, kind: "pickup", pickup: { effect: "bomb" } });
496
+ prefab("reliquary", { model: "style.lowpoly.chest", health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 1 } });
497
+ prefab("abbey_hoard", { model: "style.lowpoly.chest", appearance: { tint: "#ffcf5a", scale: 1.6 }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 3, evolve: true } });
498
+
499
+ // Shards (pickups): the XP each is worth.
500
+ prefab("wick_small", { model: "style.lowpoly.gem_small", health: 1, speed: 0, kind: "pickup", value: 1 });
501
+ prefab("wick_mid", { model: "style.lowpoly.gem_mid", health: 1, speed: 0, kind: "pickup", value: 6 });
502
+ prefab("wick_large", { model: "style.lowpoly.gem_large", health: 1, speed: 0, kind: "pickup", value: 60 });
503
+
504
+ // Weapons fire by themselves. Cooldowns in seconds, ranges in metres. Each pairs with a passive to evolve.
505
+ weapon("candle_burst", {
506
+ kind: "nova", damage: 13, cooldown: 1.1, range: 3.8, knockback: 2.5,
507
+ levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { range: "+15%" }, { damage: "+25%", knockback: 3 }],
508
+ evolve: { into: "chapel_blaze", requires: "bellows" },
509
+ });
510
+ weapon("wax_dart", {
511
+ kind: "bolt", damage: 14, cooldown: 0.6, range: 13, speed: 24, pierce: 1, knockback: 2,
512
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { pierce: 1 }, { count: 1 }, { damage: "+40%" }],
513
+ evolve: { into: "wax_volley", requires: "twin_wick" },
514
+ });
515
+ weapon("censer_chain", {
516
+ kind: "orbit", damage: 8, cooldown: 0.1, range: 2.4, count: 2, speed: 3.6, knockback: 3,
517
+ levels: [{ count: 1 }, { damage: "+30%" }, { range: "+20%" }, { count: 1 }, { speed: "+25%" }, { damage: "+40%" }],
518
+ evolve: { into: "thurible_halo", requires: "stained_glass" },
519
+ });
520
+ weapon("bone_spear", {
521
+ kind: "bolt", damage: 30, cooldown: 1.6, range: 18, speed: 34, pierce: 6, knockback: 4,
522
+ levels: [{ damage: "+30%" }, { pierce: 3 }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
523
+ evolve: { into: "relic_lance", requires: "tallow_oil" },
524
+ });
525
+ weapon("sexton_scythe", {
526
+ kind: "cone", damage: 18, cooldown: 1.1, range: 4, spread: 100, knockback: 6,
527
+ levels: [{ damage: "+30%" }, { spread: 40 }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
528
+ evolve: { into: "reaper_wheel", requires: "soft_soles" },
529
+ });
530
+ weapon("vesper_arc", {
531
+ kind: "chain", damage: 16, cooldown: 1.3, range: 9, count: 2, speed: 4, knockback: 1,
532
+ levels: [{ count: 1 }, { damage: "+35%" }, { count: 1 }, { cooldown: "-20%" }, { count: 2 }],
533
+ evolve: { into: "matins_storm", requires: "lodestone" },
534
+ });
535
+ weapon("ember_halo", {
536
+ kind: "aura", damage: 5, cooldown: 0.5, range: 2.5, knockback: 1,
537
+ levels: [{ range: "+20%" }, { damage: "+40%" }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
538
+ evolve: { into: "pyre", requires: "warded_heart" },
539
+ });
540
+ weapon("falling_wax", {
541
+ kind: "meteor", damage: 40, cooldown: 2.2, range: 11, count: 1, blast: 1.8, knockback: 6,
542
+ levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
543
+ });
544
+
545
+ // Evolutions: about x1.7 damage and a new shape. Never offered on their own.
546
+ weapon("chapel_blaze", { kind: "nova", damage: 40, cooldown: 0.8, range: 8, knockback: 9 });
547
+ weapon("wax_volley", { kind: "bolt", damage: 34, cooldown: 0.4, range: 15, speed: 28, count: 6, pierce: 3, knockback: 3 });
548
+ weapon("thurible_halo", { kind: "orbit", damage: 20, cooldown: 0.1, range: 3.6, count: 6, speed: 4.5, knockback: 4 });
549
+ weapon("relic_lance", { kind: "bolt", damage: 80, cooldown: 1, range: 22, speed: 40, count: 3, pierce: 30, knockback: 6 });
550
+ weapon("reaper_wheel", { kind: "cone", damage: 45, cooldown: 0.8, range: 5.5, spread: 360, knockback: 8 });
551
+ weapon("matins_storm", { kind: "chain", damage: 34, cooldown: 0.9, range: 11, count: 9, speed: 5, knockback: 2 });
552
+ weapon("pyre", { kind: "aura", damage: 12, cooldown: 0.4, range: 4.5, knockback: 2 });
553
+
554
+ // Passive upgrades offered at level-up next to new weapons and weapon levels. Each evolution pairs with one.
555
+ upgrade("soft_soles", { stat: "speed", step: 0.07, maxLevel: 5 });
556
+ upgrade("warded_heart", { stat: "maxHealth", step: 20, maxLevel: 5 });
557
+ upgrade("lodestone", { stat: "magnet", step: 0.8, maxLevel: 5 });
558
+ upgrade("tallow_oil", { stat: "might", step: 0.1, maxLevel: 5 });
559
+ upgrade("bellows", { stat: "haste", step: 0.06, maxLevel: 5 });
560
+ upgrade("hymnal", { stat: "regen", step: 1, maxLevel: 3 });
561
+ upgrade("stained_glass", { stat: "area", step: 0.1, maxLevel: 5 });
562
+ upgrade("twin_wick", { stat: "amount", step: 1, maxLevel: 2 });
563
+
564
+ /**
565
+ * What a woken room holds, by its depth (doors from the start): [prefab, count] for one
566
+ * wickbearer. Each extra wickbearer adds 35% more. Deeper rooms repeat the last row.
567
+ */
568
+ const ENCOUNTERS: readonly (readonly (readonly [string, number])[])[] = [
569
+ [["bone_rattler", 7], ["crypt_bat", 5]],
570
+ [["bone_rattler", 9], ["bone_archer", 3], ["grave_shambler", 2]],
571
+ [["grave_shambler", 3], ["crypt_bat", 9], ["wax_wisp", 4], ["bone_archer", 3]],
572
+ [["tomb_slime", 3], ["hex_imp", 2], ["bone_rattler", 10], ["grave_ram", 2], ["candle_mender", 1]],
573
+ [["ossuary_brute", 1], ["bone_archer", 5], ["wax_wisp", 5], ["grave_ram", 3], ["candle_mender", 2], ["bone_rattler", 10]],
574
+ ];
575
+ /** Everything a room can send: a room is cleared when none of these is left standing. */
576
+ const DEAD = ["bone_rattler", "crypt_bat", "grave_shambler", "bone_archer", "wax_wisp", "tomb_slime", "hex_imp", "candle_mender", "ossuary_brute", "grave_ram", "marrow_knight", "wax_abbot"];
577
+ /** Seconds the crypt stays quiet with no fight before the nearest sleeping room wakes by itself. */
578
+ const STIR_AFTER = 18;
579
+
580
+ system("crawl", ({ onTick, tick, players, spawn, alive, emit, announce, upcoming, respawnDead, spawnPickup, scatter, random, onScreen, on, end, clear, bossAlive }) => {
581
+ scatter("crypt_urn", 36);
582
+ scatter("bone_pile", 14);
583
+ const woken = new Set<number>([CRYPT.start.id]);
584
+ const cleared = new Set<number>([CRYPT.start.id]);
585
+ const active: number[] = [];
586
+ // Spawns waiting for a spot in their room that nobody is looking at.
587
+ const queue: { prefab: string; room: number; elite: boolean; since: number }[] = [];
588
+ let quietSince = tick();
589
+ let fightSince = 0;
590
+ let wakes = 0;
591
+ let knightDone = false;
592
+
593
+ const living = () => DEAD.reduce((n, id) => n + alive(id), 0);
594
+ const pointIn = (room: number, margin: number): [number, number] => {
595
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
596
+ return [r.x + (random() * 2 - 1) * (r.hx - margin), r.z + (random() * 2 - 1) * (r.hz - margin)];
597
+ };
598
+
599
+ const wake = (room: number) => {
600
+ if (woken.has(room)) return;
601
+ woken.add(room);
602
+ if (active.length === 0) fightSince = tick();
603
+ active.push(room);
604
+ wakes++;
605
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
606
+ const party = Math.max(1, players().filter((p) => !p.bot).length);
607
+ const add = (prefab: string, count: number, elite = false) => {
608
+ for (let i = 0; i < count; i++) queue.push({ prefab, room, elite, since: tick() });
609
+ };
610
+ if (room === CRYPT.boss.id) {
611
+ announce("THE WAX ABBOT WAKES", { tone: "danger" });
612
+ add("wax_abbot", 1);
613
+ add("bone_rattler", 6);
614
+ return;
615
+ }
616
+ const row = ENCOUNTERS[Math.min(r.depth - 1, ENCOUNTERS.length - 1)] ?? ENCOUNTERS[0] ?? [];
617
+ for (const [prefab, n] of row) add(prefab, Math.max(1, Math.round(n * (1 + 0.35 * (party - 1)))));
618
+ // Every third room brings an elite; the Marrow Knight guards the first room three doors down.
619
+ if (wakes % 3 === 0) add("bone_rattler", 1, true);
620
+ if (!knightDone && r.depth >= 3) {
621
+ knightDone = true;
622
+ announce("THE MARROW KNIGHT STIRS", { tone: "danger" });
623
+ add("marrow_knight", 1);
624
+ }
625
+ };
626
+
627
+ const clearActive = () => {
628
+ for (const room of active) {
629
+ cleared.add(room);
630
+ const r = CRYPT.rooms[room] ?? CRYPT.start;
631
+ if (room !== CRYPT.boss.id) spawnPickup("reliquary", [r.x, 0.5, r.z]);
632
+ emit("room.cleared");
633
+ }
634
+ active.length = 0;
635
+ respawnDead();
636
+ announce("THE ROOM FALLS SILENT", { tone: "reward" });
637
+ quietSince = tick();
638
+ };
639
+
640
+ onTick(() => {
641
+ const t = tick();
642
+ // Bring in what waits, off every screen, inside its own room (after 3 s, wherever it fits).
643
+ for (let i = 0; i < queue.length && i < 8; i++) {
644
+ const q = queue[i];
645
+ if (!q) continue;
646
+ const late = t - q.since > 90;
647
+ for (let k = 0; k < 4; k++) {
648
+ const [x, z] = pointIn(q.room, 1.5);
649
+ if (!late && onScreen(x, z)) continue;
650
+ spawn(q.prefab, [x, 0.5, z], { elite: q.elite });
651
+ queue.splice(i--, 1);
652
+ break;
653
+ }
654
+ }
655
+ if (t % 10 !== 0) return;
656
+ // Where the party stands wakes the rooms around it.
657
+ for (const p of players()) {
658
+ if (!p.alive) continue;
659
+ const here = CRYPT.roomAt(p.position[0], p.position[2]);
660
+ if (here < 0) continue;
661
+ if (!woken.has(here) && active.length < 2) wake(here);
662
+ if (cleared.has(here)) for (const next of CRYPT.rooms[here]?.links ?? []) if (!woken.has(next) && active.length < 2) wake(next);
663
+ }
664
+ if (active.length > 0) {
665
+ // A long fight down to its last few: the stragglers slink back into the dark (never a boss).
666
+ if (queue.length === 0 && !bossAlive() && t - fightSince > 75 * 30 && living() <= 3) for (const id of DEAD) clear(id);
667
+ if (queue.length === 0 && living() === 0) clearActive();
668
+ return;
669
+ }
670
+ // Quiet too long: the nearest sleeping room next to a cleared one stirs by itself.
671
+ if (t - quietSince >= STIR_AFTER * 30) {
672
+ let pick = -1;
673
+ for (const r of CRYPT.rooms) {
674
+ if (woken.has(r.id) || !r.links.some((l) => cleared.has(l))) continue;
675
+ if (pick < 0 || r.depth < (CRYPT.rooms[pick]?.depth ?? 99)) pick = r.id;
676
+ }
677
+ if (pick >= 0) {
678
+ announce("SOMETHING STIRS IN THE DARK", { tone: "danger" });
679
+ wake(pick);
680
+ }
681
+ } else if (t - quietSince === 30) upcoming("THE CRYPT STIRS", STIR_AFTER - 1);
682
+ });
683
+
684
+ on("miniboss.killed", () => announce("THE KNIGHT FALLS · TAKE THE HOARD", { tone: "reward" }));
685
+ on("boss.killed", () => end("won"));
686
+ });
687
+
688
+ // Keepers: pick one before your first level. New keepers unlock with achievements, never with coins.
689
+ keeper("lampwright", { name: "Lampwright", text: "A steady candle burst and a long reach for shards.", weapon: "candle_burst", passive: "lodestone" });
690
+ keeper("sexton", { name: "Sexton", text: "A sweeping scythe and soft soles.", model: "style.lowpoly.hooded_adventurer", tint: "#9fd17a", weapon: "sexton_scythe", passive: "soft_soles", unlock: "three_doors" });
691
+ keeper("vesper", { name: "Vesper", text: "Arcs that leap between the dead; quicker hands.", model: "style.lowpoly.mini_female_c", tint: "#7ad7ff", weapon: "vesper_arc", passive: "bellows", unlock: "knight_felled" });
692
+ keeper("chandler", { name: "Chandler", text: "Calls falling wax; a wider glass.", model: "style.lowpoly.monarch", tint: "#ffcf7a", weapon: "falling_wax", passive: "stained_glass", unlock: "abbot_silenced" });
693
+
694
+ // Achievements (counted in a run; they unlock keepers and pay nothing).
695
+ achievement("three_doors", { title: "Three Doors Down", text: "Clear three rooms in one descent.", when: "room.cleared", n: 3 });
696
+ achievement("knight_felled", { title: "Marrow and Bone", text: "Fell the Marrow Knight.", when: "miniboss.killed" });
697
+ achievement("abbot_silenced", { title: "The Candle Outlasts", text: "Silence the Wax Abbot.", when: "boss.killed" });
698
+ achievement("evolver", { title: "Rekindled", text: "Evolve a weapon.", when: "player.evolved" });
699
+
700
+ // Daily goals: the HUD shows three a day. They point at the game's systems and pay nothing.
701
+ mission("rooms", { text: "Clear 4 rooms", when: "room.cleared", n: 4 });
702
+ mission("elite", { text: "Kill an elite", when: "elite.killed" });
703
+ mission("revive", { text: "Revive a wickbearer", when: "player.revived" });
704
+ mission("chest", { text: "Open 3 reliquaries", when: "chest.collected", n: 3 });
705
+ mission("knight", { text: "Fell the Marrow Knight", when: "miniboss.killed" });
706
+ mission("evolve", { text: "Evolve a weapon", when: "player.evolved" });
707
+
708
+ // End-screen titles: who led the descent at what.
709
+ title("gravedigger", { text: "The Gravedigger", most: "kills" });
710
+ title("pyre", { text: "The Pyre", most: "damage" });
711
+ title("chaplain", { text: "Chaplain", most: "revives" });
712
+ title("unscathed", { text: "Unscathed", least: "downs" });
713
+ title("magpie", { text: "Magpie", most: "pickups" });
714
+
715
+ // Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each.
716
+ // Pay amounts copy EMBERWATCH's (5, 20, 50, 5) until CRYPTWICK's own are set: PLACEHOLDER.
717
+ earn.rule("first_doors", { when: "room.cleared", n: 2, pay: 5, oncePer: "run" });
718
+ earn.rule("deep_delve", { when: "room.cleared", n: 7, pay: 20, oncePer: "run" });
719
+ earn.rule("abbot_breaker", { when: "boss.killed", n: 1, pay: 50, oncePer: "run" });
720
+ earn.rule("bone_breaker", { when: "enemy.killed", n: 60, pay: 5, oncePer: "run" });
721
+ // Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
722
+ // A staked run also pays up to stake x multiple, by the share of this run's earn the player makes.
723
+ // What it does not win back stays in the reward pool. Values are PLACEHOLDER.
724
+ earn.stake({ min: 20, max: 200, multiple: 2 });
725
+
726
+ // Store (the Kit), priced in coins (the default coin model): a revive used while
727
+ // downed, and a cosmetic wickbearer palette owned for good (kept in the profile).
728
+ // Prices copy EMBERWATCH's until CRYPTWICK's own are set: PLACEHOLDER.
729
+ store.sku("revive", { coins: 50 });
730
+ store.sku("skin_ossuary", { coins: 300, grants: { cosmetic: 1 } });
731
+ // Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
732
+ // and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
733
+ store.sku("wick_charm", { coins: 150, grants: { gear: "tallow_oil", loseBelow: 0.25 } });
734
+ ```
735
+
736
+ ## Validate and playtest
737
+
738
+ ### With the MCP server
739
+
740
+ Run `npx -y @netforge/mcp` (package @netforge/mcp, bin netforge-mcp) and
741
+ connect it to your coding agent. Its tools are netforge_list_templates, netforge_create_game,
742
+ netforge_explain_api, netforge_search_docs, netforge_validate_game, netforge_run_playtest and netforge_play_locally.
743
+
744
+ - netforge_validate_game checks src/game.ts against the engine's schemas and this
745
+ template's rules, and returns one actionable line per problem.
746
+ - netforge_run_playtest plays the game headless with bots and returns the report.
747
+ - netforge_play_locally gives the steps to open the game in a browser with kit play.
748
+
749
+ ### In code
750
+
751
+ Import the game module first so its declarations register, then run the
752
+ headless harness. This plays 4 players for 3 minutes at 30 Hz.
753
+
754
+ ```ts
755
+ import { engine } from "@netforge/engine";
756
+ import "./src/game.js";
757
+
758
+ const report = await engine.test.run({
759
+ ticks: 30 * 180,
760
+ bots: 4,
761
+ seed: 1,
762
+ assert: [
763
+ { name: "earn fired", check: (r) => r.earnEmitted > 0 },
764
+ { name: "no softlock", check: (r) => !r.softlock },
765
+ ],
766
+ });
767
+ if (report.failed.length > 0) throw new Error(report.failed.join("\n"));
768
+ ```
769
+
770
+ The report has kills, wave (1 + rooms cleared), maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick; with bot: "human" it also fills report.fun.
771
+
772
+ The template's own tests (vitest) are the reference checks:
773
+
774
+ - test/harness.test.ts
775
+ - test/host.test.ts
776
+ - test/store.test.ts
777
+
778
+ ### Play it locally
779
+
780
+ In a folder with netnet.json and src/game.ts:
781
+
782
+ ```sh
783
+ npm install @netforge/kit-play @netforge/engine @netforge/template-dungeon
784
+ echo '{"template":"dungeon"}' > netnet.json
785
+ npx kit-play # opens http://127.0.0.1:4663
786
+ ```
787
+
788
+ kit play builds the template client with your src/game.ts and runs a local
789
+ room host with play money. No wallet, no chain and no hosted servers are
790
+ involved. Open the link in more tabs for more players.
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # CRYPTWICK: the co-op dungeon template (NetNet game runtime, Template 3)
1
+ # Net Forge co-op dungeon template (CRYPTWICK)
2
2
 
3
3
  > A co-op dungeon crawl for up to 4 players: a procedural crypt of rooms
4
4
  > from a seed, rooms that wake as the party nears them, loot in every
package/package.json CHANGED
@@ -1,12 +1,35 @@
1
1
  {
2
2
  "name": "@netforge/template-dungeon",
3
- "version": "0.1.0",
4
- "description": "CRYPTWICK: Template 3 of the NetNet game runtime, a co-op dungeon crawl for 4 players: procedural rooms, loot and permadeath runs. The game module, its browser client and its playtest bots.",
3
+ "version": "0.2.0",
4
+ "description": "Co-op dungeon crawler game template for Net Forge: 4 players, procedural rooms, loot and permadeath runs (CRYPTWICK).",
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
+ "dungeon-crawler",
18
+ "roguelike"
19
+ ],
5
20
  "license": "MIT",
6
21
  "type": "module",
22
+ "sideEffects": [
23
+ "./src/game.ts",
24
+ "./src/manifest.ts",
25
+ "./client/**"
26
+ ],
7
27
  "exports": {
8
28
  ".": "./src/manifest.ts",
9
- "./store": "./src/store.ts"
29
+ "./store": "./src/store.ts",
30
+ "./package.json": "./package.json",
31
+ "./llms.txt": "./llms.txt",
32
+ "./llms-full.txt": "./llms-full.txt"
10
33
  },
11
34
  "files": [
12
35
  "src",
@@ -15,26 +38,32 @@
15
38
  "llms.txt",
16
39
  "README.md",
17
40
  "NOTICE",
18
- "LICENSE"
41
+ "LICENSE",
42
+ "llms-full.txt",
43
+ "AGENTS.md",
44
+ "CLAUDE.md"
19
45
  ],
20
46
  "publishConfig": {
21
47
  "access": "public"
22
48
  },
49
+ "homepage": "https://forge.netnet.capital/docs/templates/dungeon",
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-dungeon"
27
57
  },
28
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-dungeon#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": "0.1.0",
37
- "@netforge/engine-sim": "0.1.0"
64
+ "@netforge/engine": "0.2.0",
65
+ "@netforge/engine-sim": "0.2.0",
66
+ "@netforge/asset-library": "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",