@netforge/template-dungeon 0.0.0-stage → 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/llms.txt ADDED
@@ -0,0 +1,398 @@
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.
package/package.json CHANGED
@@ -1,6 +1,86 @@
1
1
  {
2
2
  "name": "@netforge/template-dungeon",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ ],
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "sideEffects": [
23
+ "./src/game.ts",
24
+ "./src/manifest.ts",
25
+ "./client/**"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/manifest.ts",
29
+ "./store": "./src/store.ts",
30
+ "./package.json": "./package.json",
31
+ "./llms.txt": "./llms.txt",
32
+ "./llms-full.txt": "./llms-full.txt"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "client",
37
+ "!client/vite.config.ts",
38
+ "llms.txt",
39
+ "README.md",
40
+ "NOTICE",
41
+ "LICENSE",
42
+ "llms-full.txt",
43
+ "AGENTS.md",
44
+ "CLAUDE.md"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "homepage": "https://forge.netnet.capital/docs/templates/dungeon",
50
+ "bugs": {
51
+ "url": "https://github.com/netnetdev/net-forge/issues"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/netnetdev/net-forge.git",
56
+ "directory": "packages/template-dungeon"
57
+ },
58
+ "engines": {
59
+ "node": ">=20"
60
+ },
61
+ "dependencies": {
62
+ "nipplejs": "1.0.4",
63
+ "three": "0.186.1",
64
+ "@netforge/engine": "0.2.0",
65
+ "@netforge/engine-sim": "0.2.0",
66
+ "@netforge/asset-library": "0.2.0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "22.20.1",
70
+ "@types/three": "0.186.0",
71
+ "tsx": "4.23.0",
72
+ "typescript": "5.8.3",
73
+ "vite": "6.4.3",
74
+ "vitest": "3.2.7"
75
+ },
76
+ "scripts": {
77
+ "build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
78
+ "typecheck": "tsc -p tsconfig.json",
79
+ "test": "vitest run",
80
+ "dev": "vite --config client/vite.config.ts",
81
+ "preview": "vite preview --config client/vite.config.ts",
82
+ "bots": "tsx scripts/load-bots.ts",
83
+ "fun": "tsx scripts/fun.ts",
84
+ "bench": "tsx scripts/bench.ts"
85
+ }
6
86
  }