@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 +74 -0
- package/CLAUDE.md +11 -0
- package/README.md +74 -44
- package/client/main.ts +2 -2
- package/llms-full.txt +790 -0
- package/llms.txt +1 -1
- package/package.json +37 -11
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
|
-
#
|
|
1
|
+
# @netforge/template-dungeon
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
start room at the top and the Wax Abbot in the deepest one. Rooms wake as
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
13
|
-
| `src/manifest.ts` | Loads and compiles
|
|
14
|
-
| `src/store.ts` | Store hooks on the Kit's API names (`kit.coins.*`, `kit.store.*`): a revive
|
|
15
|
-
| `client/` | The browser client: three.js scene, the crypt's floors and walls
|
|
16
|
-
| `test/` | Playtest bots through `engine.test.run`, and the store. |
|
|
17
|
-
| `llms.txt` | How to change the game
|
|
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
|
-
##
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/template-dungeon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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/
|
|
36
|
-
"@netforge/engine": "0.
|
|
37
|
-
"@netforge/
|
|
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",
|