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