@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/AGENTS.md +74 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +35 -0
- package/README.md +84 -2
- package/client/crypt.ts +274 -0
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +577 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +55 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +790 -0
- package/llms.txt +398 -0
- package/package.json +83 -3
- package/src/game.ts +328 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|