@netforge/engine 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/README.md CHANGED
@@ -1,148 +1,101 @@
1
1
  # @netforge/engine
2
2
 
3
- The client side of the Net Forge game engine.
3
+ The Net Forge game engine client: a typed, declarative game API and a three.js renderer (WebGPU with a WebGL2 fallback) for multiplayer browser games.
4
4
 
5
- Two entry points, on purpose:
5
+ One simulation is shared by client and server (an ECS with Rapier physics, a fixed tick and snapshots, in `@netforge/engine-sim`), ten finished multiplayer games ship as templates (`@netforge/template-*`), and the whole API is built to be written by your own coding agent.
6
6
 
7
- | Import | Who uses it | What it holds |
8
- |---|---|---|
9
- | `@netforge/engine` | generated game code (the model) | `game`, `prefab`, `system`, `earn`, `store`, `engine.test.run`: declarative, Zod-validated, no DOM, no renderer, no netcode |
10
- | `@netforge/engine/runtime` | templates, the Net Forge shell, the demo | three.js renderer (WebGPU, WebGL2 fallback), `InstancedCrowd`, `CameraRig`, `InputController` (keyboard/mouse, nipplejs touch), `AssetLoader` (glTF + meshopt + KTX2), `ArenaClient`, `SystemScheduler`, `collectManifest` |
11
-
12
- A game module never reaches the renderer, netcode or physics.
13
- Bad declarations throw `GameDefinitionError` with one actionable line per
14
- issue, e.g. `model: reference assets by library id (e.g. "style.lowpoly.bat"), never by URL or path`.
15
-
16
- ## Status (Template 1)
17
-
18
- - Declarations, validation, manifest cross-checks and the tick-based
19
- `SystemScheduler`, plus `weapon()`, `upgrade()`, pickup prefabs, bosses,
20
- and systems' `alive()`, `on(event)`, `respawnDead()`, `end(outcome)`.
21
- - `@netforge/engine/game` runs a declared game on engine-sim:
22
- `compileGame` (manifest to sim content), `GameRunner` (systems on a run
23
- clock, game events, run phases), `EarnTracker`, the engine-sim
24
- `SimAdapter` (`engineSimAdapter`, wire schema `engineSchema`) and a
25
- playtest bot. `engine.test.run` plays the declared game when there is one
26
- (kills, waves, levels, earn by rule, softlock, ms/tick).
27
- - `@netforge/engine/net` is the client half of the netcode (the server
28
- half runs in the hosted Net Forge room server): prediction and reconciliation, interpolation, delta
29
- snapshots with per-kind fields, input packets, client clock adjustment
30
- for one-input-per-tick sims, the wire protocol and the Colyseus room client.
31
- - `AssetLoader` is built but no template uses it yet (Template 1 art is
32
- procedural, see packages/template-survivor/client/library.ts).
33
-
34
- ## Status (Template 2, the arena shooter)
35
-
36
- - `game({ template: "arena", arena, round, players: { loadout, bots,
37
- respawn, drop } })`, weapon kinds `blaster` (with `spread`) and `beam`,
38
- and `emit(event, players?)` for crediting chosen players.
39
- - `compileGame` builds the yard (floor, pillars, spawns) and the arena
40
- rules; it refuses survivor weapons in an arena and arena weapons in a
41
- survivor game, with a line the model can act on.
42
- - `GameRunner` adds the round clock and frag limit (a round is a run),
43
- `opponent.killed` / `player.died` / `round.top` / `round.finished`
44
- events, kills and deaths per player, a kill feed in `RunInfo`, and seat
45
- bots (`players.bots`) that fill empty seats while a human is present and
46
- never earn. Killing a bot scores and shows in the feed but pays no earn
47
- rule, and `round.top` pays only when another person was in the round
48
- (both arrive as unpaid events, and so does anything a system emits while
49
- handling one). Bots play through `arenaBotInput` (game/arena-bot.ts), a pure
50
- function of the present, so a room's run replays exactly.
51
- - `engineSimAdapter` gives arena games the lag-compensation hooks the room
52
- server's core already calls (`hitscans`, `hitboxes`, `applyHit`), runs
53
- the client without authority, and grants store upgrades by name.
54
- - `engine.test.run` plays arena games with the bots and reports kills,
55
- deaths, rounds, `topScore` and `scorers`.
56
- - The registry seals a game once its manifest is collected, so one process
57
- (the room server) can load several game modules in turn.
58
-
59
- ## Board genres (`@netforge/engine/rules`)
60
-
61
- Tower defense, idle, card battler and monster collector do not run on
62
- engine-sim's action world. Each is a deterministic rules module (integer
63
- state, a seeded PRNG word, no clocks) behind the same `SimAdapter` seam,
64
- so the room server, the netcode, the referee, run verification, the store,
65
- the stake and losable gear are the ones every template uses.
66
-
67
- - `RulesGenre<S>` (rules/kit.ts): `create`, `join`, `act` (a player's
68
- validated action), `step` (one tick), `outcome`, `view(state, player)`
69
- (what that player may see: hidden hands stay hidden), `entities` (moving
70
- things, interpolated on the client) and the genre's own bot.
71
- - `rulesAdapter(genre, manifest)`: earn rules per player per run
72
- (`EarnTracker`), seat bots that never earn, unpaid events, the late-start rule
73
- (world progress never pays a run that started after the world began),
74
- store upgrades and gear as recorded room commands capped at maxLevel,
75
- cosmetics, the end screen and `run.reset`. Each player's avatar (a cursor,
76
- a builder, a walking hero) is predicted from its own input.
77
- - `SimAdapter.infoFor(world, player)`: run state sent to one player only
78
- (the room sends it when it changed, every `infoEveryTicks`).
79
- - Declarations: each genre adds its own typed functions to this package's
80
- root (tower defense: `towerBoard`, `tower`, `waves`), validated by its own
81
- Zod schemas and stored with `declareContent`. `engine.test.run` plays a
82
- board genre through `playRules` and reports its own checks under
83
- `rules.checks`.
84
-
85
- ## Game feel kit (`runtime/feel`)
86
-
87
- One-line components every template uses. `FeelKit` wraps them:
88
- `hit`, `kill`, `hurt`, `levelUp`, `land`, `dash` and `footstep`, with the
89
- presets `subtle`, `default` and `heavy`. The parts work alone too:
90
-
91
- - `Trauma`: seeded-noise trauma shake.
92
- - `FeelClock`: hit-stop and slow motion with a smooth catch-up; the netcode never pauses.
93
- - `HitFlash` and `KnockbackOffsets`: flash and spring squash per entity id.
94
- - `Spring` and `Spring3`.
95
- - `DamageNumbers`: pooled, and merges rapid hits on one id.
96
- - `Tweens` and `Ease`, with `pop`, `shakeEl` and `countUp` for UI.
97
- - `FeelAudio`: procedural cues with throttles, voice limits, pan and distance falloff; `addBuffer` takes CC0 samples later. `Footsteps` alongside it.
98
- - `Rumble`: Gamepad dual-rumble, with a `navigator.vibrate` fallback.
99
-
100
- `CameraRig` adds `addTrauma`, `punch` (an FOV kick), a smoothed and
101
- clamped lead and a dead zone. Its old `shake(amplitude, seconds)` still
102
- works.
103
-
104
- ## Render pipeline (`runtime/render`)
105
-
106
- `createRenderStack(handle, scene, camera, { preset, quality, bloom, outline })`
107
- returns the whole pipeline. Call `stack.frame(dt, focus)` in place of
108
- `renderer.render(scene, camera)`. It includes:
109
-
110
- - `LightingRig`: hemisphere light plus a sun whose shadow follows the focus, texel-snapped.
111
- - `PostFX`: bloom, colour grade, vignette and hurt pulse, tone mapping applied once, FXAA, and an optional depth outline.
112
- - `Atmosphere`: fog and a sky gradient.
113
- - `QualityManager`: tiers and dynamic resolution.
114
- - `GpuParticles`: one draw, with motion on the GPU.
115
- - `GroundDecals` and `GroundRings`.
116
-
117
- It runs on WebGPU and on WebGL2. Animated characters:
118
-
119
- - `AnimatedCrowd`: procedural vertex animation for instanced crowds.
120
- - `VatCrowd` and `bakeVertexAnimation`: vertex-animation textures for skinned crowds.
121
- - `AnimatedCharacter`: glTF skinned meshes on an `AnimationMixer` with locomotion blending.
122
- - `buildSegmentRig`: a procedural rig for tests and demos.
123
-
124
- The demo shows all of it with `?fx=1` (`&outline=1`, `&crowd=vat`,
125
- `&preset=dusk`, `&quality=low`).
126
-
127
- Game code declares the look and feel it wants: `game({ look })`,
128
- `prefab({ feel })` and `weapon({ feel })`, validated by `lookSchema` and
129
- `feelSchema`. See `llms.txt`.
130
-
131
- ## Demo
7
+ ## Install
132
8
 
133
9
  ```sh
134
- pnpm --filter @netforge/engine-sim build
135
- pnpm --filter @netforge/engine demo # http://localhost:5310
136
- # ?enemies=2000 ?webgl=1 (force the WebGL2 backend) ?autopilot=1 (scripted input)
10
+ npm install @netforge/engine
137
11
  ```
138
12
 
139
- Measure in headless Chrome (raw CDP, no puppeteer dependency):
13
+ Node 20 or later. The package ships ES modules with TypeScript types.
140
14
 
141
- ```sh
142
- pnpm --filter @netforge/engine demo:build
143
- pnpm --filter @netforge/engine exec vite preview --config demo/vite.config.ts --port 5311
144
- CHROME_BIN=/path/to/chrome node packages/engine/scripts/measure-demo.mjs "http://localhost:5311/?autopilot=1" 20 --enable-unsafe-webgpu --use-angle=metal
15
+ ## Example
16
+
17
+ A game is one file, `src/game.ts`, that declares what the game is. It imports only `@netforge/engine` and never touches the renderer, the netcode or the physics. This is a cut-down version of the arena shooter template:
18
+
19
+ ```ts
20
+ import { earn, game, prefab, store, weapon } from "@netforge/engine";
21
+
22
+ export default game({
23
+ template: "arena",
24
+ style: "lowpoly",
25
+ places: { yard: { chunks: "places/yard", spawn: [0, 1, 0] } },
26
+ players: { prefab: "style.lowpoly.mini_male_f", max: 16, loadout: ["arc_rifle"], bots: 6, respawn: 3, drop: "volt_cell" },
27
+ arena: { halfSize: 26, pillars: [[0, 0, 2.6]], spawns: [[22, 22], [-22, -22], [22, -22], [-22, 22]] },
28
+ round: { seconds: 180, frags: 25 },
29
+ });
30
+ prefab("volt_cell", { model: "style.lowpoly.cell_heal", health: 1, speed: 0, kind: "pickup", value: 35 });
31
+ weapon("arc_rifle", { kind: "blaster", damage: 12, cooldown: 0.15, range: 26, speed: 44 });
32
+ earn.rule("live_wire", { when: "opponent.killed", n: 3, pay: 4, oncePer: "run" });
33
+ earn.stake({ min: 10, max: 100, multiple: 2 });
34
+ store.sku("skin_neon", { coins: 300, grants: { cosmetic: 1 } });
145
35
  ```
146
36
 
147
- The script always launches with `--use-mock-keychain --password-store=basic`
148
- and a throwaway profile, so it never prompts for the macOS keychain.
37
+ Then play it headless with bots and check it:
38
+
39
+ ```ts
40
+ import { engine } from "@netforge/engine";
41
+
42
+ const report = await engine.test.run({
43
+ ticks: 60 * 240,
44
+ bots: 4,
45
+ assert: [{ name: "someone scores", check: (r) => r.kills > 0 }],
46
+ });
47
+ console.log(report.kills, report.earned, report.softlock, report.msPerTick);
48
+ ```
49
+
50
+ Bad declarations throw `GameDefinitionError` with one actionable line per issue, for example `model: reference assets by library id (e.g. "style.lowpoly.bat"), never by URL or path`.
51
+
52
+ ## Entry points
53
+
54
+ | Import | Who uses it | What it holds |
55
+ |---|---|---|
56
+ | `@netforge/engine` | game code (`src/game.ts`) | `game`, `prefab`, `weapon`, `upgrade`, `system`, `director`, `earn`, `store`, `keeper`, `achievement`, `mission`, `title`, `economy`, `metaUpgrade`, `dungeon`, the board genre declarations (`towerBoard`, `tower`, `waves`, `saltFlat`, `plant`, `marsh`, `creature`, `move`, `card`, `cardTable`), and `engine.test.run`. Declarative, Zod-validated, no DOM. |
57
+ | `@netforge/engine/runtime` | template clients | the three.js renderer (`createRenderer`), `createRenderStack`, `CameraRig`, `InputController` (keyboard, mouse, gamepad, touch), `AssetLoader` (glTF, meshopt, KTX2), `FeelKit` and its parts, `SoundPlayer`, `ArenaClient`, `SystemScheduler`, `collectManifest`. |
58
+ | `@netforge/engine/game` | templates, room hosts, tests | `compileGame`, `collectManifest`, `GameRunner`, `EarnTracker`, `engineSimAdapter` (the SimAdapter on `@netforge/engine-sim`). |
59
+ | `@netforge/engine/net` | template clients | the client half of the netcode: prediction and reconciliation, interpolation, delta snapshots, input packets, clock adjustment and the room client. |
60
+ | `@netforge/engine/rules` | board genres | `rulesAdapter`, `playRules` and the genre modules: deterministic rules (tower defense, idle, cards, collector) behind the same SimAdapter seam. |
61
+ | `@netforge/engine/voxel` | template clients | the voxel style's core. |
62
+ | `@netforge/engine/earn-source` | tools | reads and rewrites the `earn.rule(...)` lines of a `src/game.ts` as source text. |
63
+
64
+ Game code imports only the root. Everything else is template machinery.
65
+
66
+ ## What the root API covers
67
+
68
+ - **Templates**: `TEMPLATES` lists them: `survivor`, `arena`, `dungeon`, `racer`, `platformer`, `tower-defense`, `party`, `idle`, `cards` and `collector`.
69
+ - **Art**: `game({ style })` picks one of `STYLES`: `pixel`, `lowpoly`, `toon`, `voxel`, `ps1` or `handdrawn`. Models are asset library ids such as `"style.lowpoly.ghost"`, never URLs or file paths.
70
+ - **Look and feel**: `game({ look })`, `prefab({ feel })` and `weapon({ feel })` are presentation only and never change the simulation.
71
+ - **Earn**: every game declares at least one `earn.rule(...)` and exactly one `earn.stake({ min, max, multiple })`. The build fails without them. Ids are lower_snake_case.
72
+ - **Coins and the store**: `store.sku(...)` sells in-run consumables, cosmetics and unlocks by default. Permanent stat upgrades are opt-in through `economy({ metaUpgrades: true })` and `metaUpgrade(...)`, capped by the engine (`META_MAX_PER_STAT`, `META_MAX_TOTAL`).
73
+ - **Systems**: `system(id, ctx => ...)` gets `every`, `after`, `on`, `emit`, `spawn`, `spawnEdge`, `players`, `end` and a seeded `random`, on a tick clock.
74
+ - **Playtest**: `engine.test.run({ ticks, bots, seed?, enemies?, bot?, assert? })` plays the game on the server sim and reports kills, deaths, waves, runs, earn by rule, softlocks, ms per tick and fun metrics.
75
+
76
+ `GAME-DESIGN.md` holds the design rules the playtest gate measures (a readable opening, fast rewards, no on-screen spawns) and the recommended coin model.
77
+
78
+ ## For AI agents
79
+
80
+ - Read `llms.txt` (look, feel, physics and rendering) and `llms-full.txt` (`llms.txt`, `GAME-DESIGN.md` and the API reference in one file).
81
+ - Each template package (`@netforge/template-*`) ships its own `llms.txt`, `llms-full.txt` and `AGENTS.md` for its genre.
82
+ - The Net Forge MCP server gives an agent the same tools over stdio: `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `netforge-mcp`). Tools: `netforge_list_templates`, `netforge_create_game`, `netforge_explain_api`, `netforge_search_docs`, `netforge_validate_game`, `netforge_run_playtest`, `netforge_play_locally`.
83
+
84
+ 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 add the server with `claude mcp add netforge -- npx -y @netforge/mcp`. Any other MCP client takes this config:
85
+
86
+ ```json
87
+ { "mcpServers": { "netforge": { "command": "npx", "args": ["-y", "@netforge/mcp"] } } }
88
+ ```
89
+
90
+ ## Play a game locally
91
+
92
+ `@netforge/kit-play` runs a game folder (`netnet.json` plus `src/game.ts`) on your own computer with play money. See its README.
93
+
94
+ ## Docs
95
+
96
+ - https://forge.netnet.capital/docs/packages/engine
97
+ - `llms.txt`, `llms-full.txt` and `GAME-DESIGN.md` in this package
98
+
99
+ ## License
100
+
101
+ MIT. See `LICENSE` and `NOTICE`.
@@ -675,8 +675,8 @@ export const earnPotSchema = z
675
675
  /** Share of every pot that stays in the reward pool (0.01 to 0.5). */
676
676
  recycle: z.number().min(0.01).max(0.5),
677
677
  /**
678
- * Pots are OFF by default until a legal review clears
679
- * them. A declared pot runs only with `enabled: true`; without it the
678
+ * Pots are off by default; the R-16 legal review approved them
679
+ * (2026-10-23), so a game opts in with `enabled: true`. Without it the
680
680
  * terms are kept for later and no room takes an entry.
681
681
  */
682
682
  enabled: z.boolean().default(false),
@@ -115,7 +115,7 @@ export interface SourceEarnPot {
115
115
  readonly entry: number;
116
116
  /** Share of every pot that stays in the reward pool. */
117
117
  readonly recycle: number;
118
- /** Pots are off by default until a legal review: true only when the line says `enabled: true`. */
118
+ /** A pot runs only when the line says `enabled: true` (off by default; R-16 approved 2026-10-23). */
119
119
  readonly enabled?: boolean;
120
120
  }
121
121
  /** The game's PvP pot, read only (null when it has none or it is not written with plain values on one line). */
@@ -205,4 +205,11 @@ export interface RunIdMessage {
205
205
  export interface CreateOptions {
206
206
  readonly place: string;
207
207
  readonly template: string;
208
+ /**
209
+ * The game's slug when one room server runs several games of one template
210
+ * (launched Net Forge games, keyed by slug). Absent: the template's own game.
211
+ */
212
+ readonly game?: string;
208
213
  }
214
+ /** A game slug as the GameFactory and the room server take it. */
215
+ export declare const GAME_SLUG_RE: RegExp;
@@ -6,3 +6,5 @@ export const MSG_SNAPSHOT = 2; // server -> client, bytes (netcode/snapshot.ts)
6
6
  export const ROOM_NAME = 'instance';
7
7
  /** Guest profile ids: 16-80 characters of A-Z, a-z, 0-9, _ or - (the same rule as Launcher guest drafts). */
8
8
  export const GUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
9
+ /** A game slug as the GameFactory and the room server take it. */
10
+ export const GAME_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -6,6 +6,12 @@ export interface JoinParams {
6
6
  readonly endpoint: string;
7
7
  readonly place: string;
8
8
  readonly template: string;
9
+ /**
10
+ * The launched game's slug: one room server runs every launched game of a
11
+ * template side by side. Absent: the page's own ?game= (Net Forge frames a
12
+ * launched game with it), else the template's own game.
13
+ */
14
+ readonly game?: string;
9
15
  readonly wallet?: string;
10
16
  /** Session token from POST /auth/sign-in. Without it the seat plays as a guest and earns nothing. */
11
17
  readonly session?: string;
@@ -53,5 +59,7 @@ export interface RoomConnection<W, I> {
53
59
  claim(): Promise<unknown>;
54
60
  leave(): Promise<void>;
55
61
  }
62
+ /** The page's ?game= slug (set by Net Forge on a launched game's frame), or null outside a browser or without one. */
63
+ export declare function pageGame(): string | null;
56
64
  export declare function joinRoom<W, I>(sim: SimAdapter<W, I>, p: JoinParams): Promise<RoomConnection<W, I>>;
57
65
  export declare function connectReservation<W, I>(sim: SimAdapter<W, I>, endpoint: string, reservation: Parameters<Client['consumeSeatReservation']>[0]): Promise<RoomConnection<W, I>>;
@@ -3,14 +3,26 @@
3
3
  // demo and the headless load bots.
4
4
  import { Client } from '@colyseus/sdk';
5
5
  import { DEFAULT_CLIENT, NetClient } from './client.js';
6
- import { MSG_INPUT, MSG_SNAPSHOT, } from './protocol.js';
6
+ import { GAME_SLUG_RE, MSG_INPUT, MSG_SNAPSHOT, } from './protocol.js';
7
+ /** The page's ?game= slug (set by Net Forge on a launched game's frame), or null outside a browser or without one. */
8
+ export function pageGame() {
9
+ try {
10
+ const g = typeof location === 'undefined' ? null : new URLSearchParams(location.search).get('game');
11
+ return g && GAME_SLUG_RE.test(g) ? g : null;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
7
17
  export async function joinRoom(sim, p) {
18
+ const game = p.game ?? pageGame();
8
19
  const res = await fetch(`${p.endpoint}/world/join`, {
9
20
  method: 'POST',
10
21
  headers: { 'content-type': 'application/json' },
11
22
  body: JSON.stringify({
12
23
  place: p.place,
13
24
  template: p.template,
25
+ ...(game ? { game } : {}),
14
26
  ...(p.wallet ? { wallet: p.wallet } : {}),
15
27
  ...(p.session ? { session: p.session } : {}),
16
28
  ...(p.guest ? { guest: p.guest } : {}),
package/llms-full.txt ADDED
@@ -0,0 +1,460 @@
1
+ # @netforge/engine (llms-full.txt)
2
+
3
+ > Everything an agent needs to write and change a Net Forge game in one file. A game is one module, src/game.ts, that imports only @netforge/engine and declares the game with game(), prefab(), weapon(), upgrade(), system(), earn.rule(), earn.stake() and store.sku(). Templates draw it, run it on the server simulation and handle netcode, physics and rendering. Part 1 is llms.txt (look, feel, physics and rendering), Part 2 is GAME-DESIGN.md (the design rules the playtest gate measures, and the coin model), Part 3 is the API reference.
4
+
5
+ ## Contents
6
+
7
+ - Part 1: llms.txt
8
+ - Part 2: GAME-DESIGN.md
9
+ - Part 3: API reference
10
+
11
+ ## Part 1: llms.txt
12
+
13
+ ## Net Forge game engine: physics, game feel and rendering (llms.txt)
14
+
15
+ > The engine every Net Forge game runs on. Two
16
+ > layers matter here:
17
+ > 1. Game code (src/game.ts) only DECLARES how things look and feel, with
18
+ > `look` on game() and `feel` on prefab() and weapon(). Templates draw it.
19
+ > 2. Template code (client/ and the sim) uses the runtime APIs below:
20
+ > the physics world in @netforge/engine-sim, and the feel kit and
21
+ > render stack in @netforge/engine/runtime.
22
+ > Generated game code never imports the runtime. It cannot reach the
23
+ > renderer, netcode or physics, and it does not need to.
24
+
25
+ ### 1. Game code: look and feel declarations
26
+
27
+ Everything here is presentation. It never changes the simulation, so it
28
+ is always safe to tune. Unknown values fail the build with a message.
29
+
30
+ #### The look of the whole game (game({ look }))
31
+
32
+ game({
33
+ template: "survivor", style: "lowpoly", places: { ... }, players: { ... },
34
+ look: { preset: "dusk", bloom: 1.3, fog: 0.4, vignette: 0.4, tint: "#ffd9a0" },
35
+ });
36
+
37
+ - preset: "night" | "dusk" | "day" | "studio". Sets the sun, sky, fog and
38
+ colour grade together. Default "night".
39
+ - bloom: glow on bright and emissive things, 0 (off) to 3. Default 1.
40
+ - shadows: sun shadows on or off (on by default above the low quality tier).
41
+ - outline: a toon outline around shapes (true or false).
42
+ - fog: 0 (clear) to 1 (thick).
43
+ - exposure (0.2 to 3), saturation (0 to 2), contrast (0.5 to 1.5),
44
+ vignette (0 to 1), tint ("#rrggbb", a grading colour over the frame).
45
+
46
+ Slow devices drop shadows, post and resolution by themselves (quality
47
+ tiers and dynamic resolution), so turning things on never breaks 60 fps.
48
+
49
+ #### How a prefab dies and takes hits (prefab({ feel }))
50
+
51
+ prefab("brute", { model: "style.lowpoly.brute", health: 160, speed: 2.2, radius: 0.8, mass: 6,
52
+ feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
53
+
54
+ #### How a weapon's impacts look (weapon({ feel }))
55
+
56
+ weapon("frost_ring", { kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
57
+ feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
58
+
59
+ feel fields, all optional:
60
+ - shake: screen shake added, 0 to 1 (0.2 a bump, 0.6 a big hit).
61
+ - hitStop: seconds the picture freezes on impact (0.03 to 0.08 feels good, 0.2 max).
62
+ - particles: "sparks" | "burst" | "embers" | "smoke" | "hitSpark" | "dust" | "magic".
63
+ - decal: "none" | "scorch" | "splat" | "ring" (left on the ground, fades).
64
+ - sound: "none" | "hit" | "crunch" | "boom" | "zap" | "chime" | "thud".
65
+ - color: "#rrggbb" for particles, decals and flashes.
66
+ - flash: false turns off the white hit flash.
67
+
68
+ #### Knockback is physics
69
+
70
+ weapon knockback is metres per second pushed into each enemy, divided by
71
+ the enemy's mass. Enemies knocked hard stagger, slide, bounce off pillars
72
+ and shove the enemies behind them. mass 1 is light, 6 is a brute, 20 a
73
+ boss (light hits barely move it).
74
+
75
+ #### Dungeon levels (game({ field }) with walls and floors)
76
+
77
+ const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
78
+ game({ template: "dungeon", ..., field: CRYPT.field });
79
+
80
+ dungeon(...) grows a floor plan from a seed: rooms on a grid, joined by
81
+ corridors, the start room centred on [0, 0] and the deepest room as
82
+ CRYPT.boss. field.walls are boxes [x, z, halfX, halfZ] that stop players,
83
+ enemies and bolts; field.floors are the only ground (spawns land on them,
84
+ and enemies behind a wall path round it through the doors on a flow
85
+ field). Systems get onScreen(x, z) to bring enemies in where nobody is
86
+ looking. A system's room logic reads CRYPT.rooms and CRYPT.roomAt(x, z).
87
+
88
+ ### 2. Template code: the physics world (@netforge/engine-sim)
89
+
90
+ Deterministic Rapier on client and server. No rendering dependency.
91
+
92
+ import { initSim, createPhysics, restorePhysics, Layer } from "@netforge/engine-sim";
93
+ await initSim();
94
+ const p = createPhysics({ gravity: [0, -20, 0], tickRate: 30 });
95
+ p.addStaticBox([30, 0.5, 30], [0, -0.5, 0], { layer: Layer.Level });
96
+ const crate = p.addBody({ type: "dynamic", shape: { box: [0.4, 0.4, 0.4] }, position: [0, 2, 0],
97
+ mass: 20, friction: 0.7, restitution: 0.2, layer: Layer.Prop });
98
+ const bolt = p.addBody({ type: "dynamic", shape: { sphere: 0.1 }, ccd: true, gravityScale: 0,
99
+ linearVelocity: [80, 0, 0], layer: Layer.Projectile, mask: Layer.Level | Layer.Enemy });
100
+ const zone = p.addTrigger({ box: [2, 2, 2] }, [5, 1, 0]);
101
+ const hero = p.addCharacter({ position: [0, 1, 0], maxSpeed: 7, jumpVelocity: 8, coyoteTicks: 5,
102
+ jumpBufferTicks: 5, airAccel: 18, dashTicks: 6, dashSpeed: 22, dashCooldownTicks: 30 });
103
+ const { events, characters } = p.step(new Map([[hero, { moveX: 1, moveZ: 0, jump: true, dash: false }]]));
104
+
105
+ - Bodies: dynamic, kinematic (setKinematicTarget) or static; box, sphere,
106
+ capsule, cylinder; mass or density, friction, restitution, damping,
107
+ gravityScale, lockRotations, sensor, ccd, userData (a NetId).
108
+ - Layers: Layer.Level, Player, Enemy, Projectile, Pickup, Prop, Trigger,
109
+ Debris, User1..8. A pair collides only when each mask holds the other.
110
+ - Events from step(): contact.start, contact.end, trigger.enter,
111
+ trigger.exit, sorted, with a < b.
112
+ - Forces: applyImpulse, applyForce (one step), applyKnockback(id, from,
113
+ strength, { lift, byMass }). Knockback works on characters too.
114
+ - Queries: raycast(origin, dir, maxDist, { mask, exclude, includeSensors })
115
+ for line of sight and hitscan; raycastAll; shapeCast; overlap.
116
+ - Joints: addJoint({ kind: "fixed" | "ball" | "hinge" | "slider", a, b }).
117
+ - Character motor: slopes (climb and slide), steps, ground snapping, coyote
118
+ time, jump buffering, variable jump height, ground and air acceleration,
119
+ terminal velocity, dash with buffer, cooldown and invulnerability,
120
+ pushing dynamic bodies. Per-step reports: grounded, landed, jumped,
121
+ dashed, hitCeiling, groundNormal. characterState / setCharacterState for
122
+ reconcile.
123
+ - Netcode: snapshot() and restorePhysics(bytes) restore bit-exact;
124
+ stateHash() hashes every pose, velocity and motor state.
125
+ - Rapier 0.21 already stops small fast spheres on thin walls; use round
126
+ projectiles (thin spinning planks can still tunnel).
127
+
128
+ ### 3. Template code: game feel (@netforge/engine/runtime)
129
+
130
+ import { FeelKit } from "@netforge/engine/runtime";
131
+ const feel = new FeelKit({ preset: "default" }); // "subtle" | "default" | "heavy"
132
+ feel.camera = rig; feel.project = (x, y, z) => scene.project(x, y, z); feel.particles = stack.particles;
133
+ // per frame
134
+ const { now, dt, realDt } = feel.tick(performance.now()); // hit-stop aware render clock
135
+ feel.hit({ id, x, y, z, amount, big, dirX, dirZ }); // flash, knockback, number, sound, shake
136
+ feel.kill({ x, y, z, big, color }); // particles, shake, hit-stop when big
137
+ feel.hurt(damage, maxHealth); // shake, stop, rumble, hurtPulse
138
+ feel.levelUp(); feel.dash(); feel.land(0.6); feel.footstep(speed);
139
+ feel.update(dt, realDt);
140
+
141
+ Parts, each usable alone: Trauma (seeded trauma shake), FeelClock (hit-stop,
142
+ slow motion, smooth catch-up), HitFlash, KnockbackOffsets (spring offsets
143
+ and squash), Spring and Spring3, DamageNumbers (pooled, merges rapid hits),
144
+ Tweens and Ease with pop / shakeEl / countUp for UI, FeelAudio (procedural
145
+ cues, throttles, voice limits, pan, distance) and Footsteps, Rumble
146
+ (Gamepad dual-rumble, navigator.vibrate fallback). CameraRig adds
147
+ addTrauma, punch (FOV kick), a smoothed and clamped lead and a dead zone.
148
+
149
+ Real sound (SoundPlayer): CC0 samples for the cues and one music loop from
150
+ the asset library's audio section. Samples replace the synth recipes under
151
+ the same cue names (the synth plays until a sample loads, or if it fails).
152
+
153
+ import { soundBank } from "@netforge/asset-library";
154
+ const sound = new SoundPlayer(feel.audio, { key: "mygame:sound", musicVolume: 0.35 });
155
+ sound.load(soundBank(libraryBase)); // cues: hit impact kill bigKill hurt levelUp gem coin dash step land boss ui pick downed revive shoot
156
+ sound.toggle(); // the mute (effects and music), remembered; returns true when muted
157
+ feel.audio.play("boss"); // any cue by name
158
+
159
+ Camera bounds: never show the void past the field.
160
+ rig.setBounds(fieldBounds(half, 16)); // the field plus 16 m of dressed outside ground
161
+ The clamp moves the camera by the overshoot of what it sees (groundFootprint,
162
+ boundsShift); dress that outside strip (ground, trees, fog).
163
+
164
+ Hero highlight: an outline and a rim on one character (the local hero), as
165
+ extra draws that share its geometry and skeleton; its materials stay as
166
+ they are.
167
+ const hl = new HeroHighlight(character.root, { rim: 0xffd27a, outline: 0x140a02, outlineWidth: 0.05 });
168
+ hl.strength = 0.4; hl.dispose();
169
+
170
+ ### 4. Template code: rendering (@netforge/engine/runtime)
171
+
172
+ const stack = createRenderStack(handle, scene, rig.camera, { preset: "night", quality: "auto", bloom: true });
173
+ stack.frame(dt, focus); // instead of renderer.render(scene, camera)
174
+ stack.particles.burst("bigKill", x, y, z, { color: 0xffc070 });
175
+ stack.decals.add("scorch", x, z, { size: 3 });
176
+ stack.rings.fire(x, z, 6, { color: 0xffb347 });
177
+ stack.post.hurt.value = feel.hurtPulse;
178
+
179
+ - LightingRig: hemisphere, sun shadow following the focus with texel
180
+ snapping, presets.
181
+ - PostFX: bloom, grade (contrast, saturation, split tone, vignette, hurt
182
+ pulse), tone mapping once, FXAA, optional depth outline.
183
+ - Atmosphere: fog (linear or exp2, height fog) and sky gradient. A high
184
+ top-down camera should set heightFog 0.
185
+ - QualityManager / DynamicResolution: tiers low, medium and high
186
+ (auto-detected), pixel ratio held to the frame budget.
187
+ - GpuParticles: one draw for every preset; motion runs on the GPU.
188
+ - GroundDecals and GroundRings: pooled, one draw each.
189
+ - AnimatedCrowd: procedural vertex animation for instanced crowds (bob,
190
+ waddle, lean, hit squash, flash). VatCrowd with bakeVertexAnimation:
191
+ skinned clips baked to a texture, thousands of instances in one draw.
192
+ AnimatedCharacter: skinned glTF with locomotion blending.
193
+ - WebGPU pipelines build on first use. Draw one of everything out of sight
194
+ at load (EMBERWATCH's SurvivorScene.warmup) so the first kill does not
195
+ hitch.
196
+
197
+ ## Part 2: GAME-DESIGN.md
198
+
199
+ ## Game design playbook
200
+
201
+ Rules every game on this engine follows. They come from measured play: the
202
+ first survivor build killed every player in 12 seconds, and a fun one kept
203
+ the same bot alive for minutes. The playtest gate measures most of them, and
204
+ a game that breaks them scores low.
205
+
206
+ ### Every genre
207
+
208
+ 1. **A readable opening, with real danger by 0:30.** The first threats
209
+ reach the player by about 0:20, and health moves by 0:30 to 0:45. The
210
+ first elite comes at about 0:45 and a mini-boss by 1:30 to 2:00. Nobody
211
+ dies in the first minute. (The first cut's playtest found no danger for
212
+ three minutes and scored 4/10. A quiet opening is not an empty one.)
213
+ 2. **Reward fast.** The first reward (a level, a kill, a pickup) comes within
214
+ about 10 seconds, then every 6 to 10 seconds for the first minute.
215
+ 3. **Never spawn on screen.** Enemies arrive from just off screen:
216
+ `director(...)` or `spawnEdge(prefab, { count })`, never `spawn()` or
217
+ `spawnRing()` at short radii. Pressure follows a moving player, because
218
+ stragglers far behind are recycled ahead of them.
219
+ 4. **Hits are survivable.** Contact damage gives i-frames
220
+ (`players.hitIframes`, 0.5 s). Twenty enemies touching you means one hit,
221
+ not twenty.
222
+ 5. **Slower than the player unless telegraphed.** Players run at 7 m/s.
223
+ Fodder moves at 45 to 60% of that, and fast runners up to 85% but fragile.
224
+ Anything faster or harder-hitting shows a telegraph first.
225
+ 6. **Telegraph every big hit.** Charges, slams, blasts and boss patterns
226
+ show a ground marker for 0.5 to 1 s, using shape and fill and not only
227
+ red. Over half of late deaths should be telegraphed ones, which means the
228
+ player misread the threat rather than the game being unfair.
229
+ 7. **Death is not a waiting room.** A downed player bleeds for about 10 s
230
+ and can crawl toward help. Each player gets one free self-revive per run.
231
+ A player who is out comes back beside a teammate within seconds
232
+ (`players.downed`), never "out until the next wave".
233
+ 8. **A run has a timeline.** It plays like a story with moments: new enemy
234
+ roles join one at a time, then elites, a mini-boss, an event, a flood, and
235
+ a final boss. Announce each moment so players always know what just
236
+ happened and where to go.
237
+ 9. **Bosses are moments.** A boss has a silhouette no mob shares and one or
238
+ two telegraphed attacks to learn. The first fight is short (about 45 s
239
+ solo), and the reward pops: a hoard chest that brings an evolution onto
240
+ the cards (`pickup: { effect: "chest", evolve: true }`).
241
+ 10. **Tougher, not more.** Past the crowd cap, the director makes new enemies
242
+ tougher instead of adding bodies. A boss alive halves the spawn rate, and
243
+ bosses scale with players present.
244
+ 11. **Builds have a goal.** Upgrades have numbers players can read ("+1 bolt",
245
+ "-20% cooldown"). Each weapon pairs with a passive toward an evolution.
246
+ Solo cards pause the game. Co-op cards do not pause, but they shove
247
+ enemies back.
248
+ 12. **Measure fun with bots.** The gate's human-like bot reacts 200 ms late,
249
+ kites, and reads cards for a second. Fix what it reports before
250
+ shipping.
251
+
252
+ ### Horde (survivor-like) targets
253
+
254
+ | Metric | Target |
255
+ |---|---|
256
+ | First level | 4-10 s |
257
+ | First damage taken | 15-45 s |
258
+ | First elite / first mini-boss | ~0:45 / 1:30-2:00 |
259
+ | First mini-boss fight, solo | ~45 s |
260
+ | Level at 1:00 / 3:00 / 6:00 | ~5 / ~10 / ~15 |
261
+ | Deaths before 1:00 | 0 |
262
+ | Enemies on screen at 0:30 | 30-50 (wave-3 density in minute 1) |
263
+ | Touches taken in minute 1 | 1-3 |
264
+ | Spawns on screen | 0% |
265
+ | Time spent downed or out | under 10% |
266
+ | Solo survival, random picks | 4-7 min |
267
+
268
+ A good curve is `director({ alive: [[0, 20], [30, 55], [60, 100], [180, 220], [300, 560], [540, 1900]], rate: [[0, 2], [30, 4], [60, 5], [180, 8], [300, 16], [540, 64]], speed: [[0, 1], [240, 1], [600, 1.6]], elites: { from: 45, every: [40, 55] } })`.
269
+ Health and damage curves grow roughly ×1.35 per minute early and flatten
270
+ later. Early gems are worth a little less (`xp` 0.7 at first), so the dense
271
+ first minute does not run the levels hot. Late `speed` keeps endless
272
+ kiting from working. The 2,000-enemy crowd is a late-run payoff, not an
273
+ opening wall.
274
+
275
+ ### Arena shooter
276
+
277
+ 1. **First kill or pickup within about 20 s.** Spawns should be close enough
278
+ to find a fight, and pickups worth walking to.
279
+ 2. **Spawn safety.** No damage in the first 2 s after spawning, and under a
280
+ quarter of deaths within 3 s of spawning. Spawn points sit out of sight
281
+ lines, away from the middle.
282
+ 3. **Contest the map.** Heals, power weapons and high ground make places
283
+ worth fighting over. Put them in the open between spawns.
284
+ 4. **Readable hits.** Hits flash, kills pop, the feed names who killed whom,
285
+ and your own shot shows instantly on your screen, before the server
286
+ agrees.
287
+ 5. **Moments.** Announce streaks, put a bounty on the leader, and run a
288
+ late-round event, so the lead changes hands. When one player has 3x the
289
+ average kills, a weapon or a position dominates.
290
+ 6. **Bots that ramp.** Seat bots fill rooms, play below a new player's level
291
+ at first and get sharper as people win. They never stomp a newcomer.
292
+ They never earn, and they are never a coin source: killing a bot scores
293
+ but pays no earn rule, and "round.top" pays only with another person in
294
+ the round. Earn comes from beating people.
295
+
296
+ ### How a change request maps to the API
297
+
298
+ - **"Harder"**: raise the director's `alive`, `rate`, `health` or `damage`
299
+ keyframes late in the run, never at 0 s.
300
+ - **"Easier"**: lower them, raise `players.hitIframes` or `magnet`, or give
301
+ the starting weapon more range.
302
+ - **"More enemy types"**: add a prefab and a pool entry with `from: <seconds>`,
303
+ one new idea at a time. Use `group: [8, 12]` for streams.
304
+ - **"Add a boss"**: add a prefab with `boss: { attacks: [...], rank }` and
305
+ spawn it with `spawnEdge(id, { count: 1 })` from `after(seconds, ...)`.
306
+ Give mini-bosses 3 attacks and the final boss 4. Its health scales with
307
+ players present.
308
+ - **"Smarter" or "trickier enemies"**: give prefabs roles (`ai: "charger"`,
309
+ `"ranged"`, `"bomber"`, `"shielder"`, `"summoner"`), or add elites.
310
+ - **"Add events" or "waves"**: use `every` or `after` in a system, with
311
+ `spawnEdge(id, { count, side: "north" })` for a tide from one side,
312
+ `xpBoost`, `surge` or a fleeing carrier with a chest. `announce` each one,
313
+ never repeat the same event twice in a row, and skip events while
314
+ `bossAlive()`.
315
+ - **"More loot" or "a map worth exploring"**: special pickups (heal,
316
+ vacuum, bomb, chest) as rare drops, props to `scatter`, and a bigger `field`.
317
+ - **"Longer or shorter run"**: move the boss's `after(...)` and stretch the
318
+ curves' keyframe times.
319
+ - **"More weapons" or "better builds"**: declare 5-8 `levels` per weapon
320
+ (each a readable change), pair weapons with passives through `evolve`, and
321
+ give evolutions a new shape. Keep weapons + evolutions within 15.
322
+ - **"Add a character" or "more heroes"**: `keeper(id, { weapon, passive,
323
+ unlock })`, unlocked by an `achievement`, or declared without an unlock
324
+ and sold (`store.sku(id, { coins, grants: { keeper: id } })`): earned or
325
+ bought, never both. A keeper is a different way to play, never a stronger
326
+ one, and the first keeper is always free.
327
+ - **"Reasons to come back"**: `achievement`s that unlock keepers,
328
+ `mission`s for daily goals, `title`s for the end screen. None pay coins;
329
+ coin rewards are earn rules, which are an economy decision. The room
330
+ server grants them from the run and saves them per player, so they follow
331
+ the player to every device.
332
+ - **"Sell character unlocks"**: a SKU per keeper with `grants: { keeper }`,
333
+ on keepers declared without an `unlock`.
334
+ - **"Cosmetics only" or "no pay to win"**: `economy({ store: "cosmetics" })`
335
+ and only SKUs with `grants: { cosmetic: n }`.
336
+ - **"Let players buy permanent upgrades with coins"**:
337
+ `economy({ metaUpgrades: true })` and a few `metaUpgrade(...)`s (below).
338
+ - **"Better co-op"**: `players.share` ("nearby" is the default), a
339
+ `reviveBuff`, `lateJoinBehind`, and `upcoming(...)` countdowns everyone
340
+ sees.
341
+
342
+ ### The recommended coin model, and why
343
+
344
+ Every game has coins (1 coin = 1 of the game's own token). How they are
345
+ spent decides whether the game stays fair to the player who never pays, and
346
+ whether a token holder can buy a win. The engine ships one recommended
347
+ model and makes the alternatives declarations, so a
348
+ change request is a one-line change, never new code.
349
+
350
+ **The default (no declaration; every template uses it).** Coins buy:
351
+
352
+ - **Cosmetics**: a look owned for good,
353
+ `store.sku("skin_aurora", { coins: 300, grants: { cosmetic: 1 } })`.
354
+ - **Unlocks**: a keeper (a character, a starting loadout, a sidegrade)
355
+ declared without an `unlock`,
356
+ `store.sku("unlock_tinker", { coins: 400, grants: { keeper: "tinker" } })`.
357
+ An unlock is a different way to play at the same power as the keepers
358
+ anyone can earn by playing. A keeper is earned (by an achievement) or
359
+ bought, never both, so a sold keeper cannot be opened another way.
360
+ - **In-run consumables**: used once, inside one run,
361
+ `store.sku("revive", { coins: 50 })` (no `grants`).
362
+
363
+ Coins never buy permanent stat power by default. A run is decided by how it
364
+ was played, and a newcomer next to a long-time holder plays the same game.
365
+
366
+ **Cosmetics only (opt-in):** `economy({ store: "cosmetics" })`. The build
367
+ refuses any SKU that is not a look.
368
+
369
+ **Meta upgrades (opt-in, never the default):** permanent stat upgrades
370
+ bought with coins, the way many survivor-likes do between runs.
371
+
372
+ ```ts
373
+ economy({ metaUpgrades: true });
374
+ metaUpgrade("hearth", { stat: "maxHealth", step: 0.05, levels: 3, cost: 120, growth: 2 }); // +5% a level: 120, 240, 480
375
+ metaUpgrade("stride", { stat: "speed", step: 0.03, levels: 3, cost: 100, growth: 1.8 });
376
+ ```
377
+
378
+ The engine enforces hard caps that no game can raise: +15% to any one stat
379
+ from meta upgrades, +30% across all of them, at most 5 levels each and 8 per
380
+ game, and geometric costs (each level at least 1.5 times the last). The
381
+ room server applies a player's levels at the start of every run, capped
382
+ again, and the store shows each level's bonus and price. Use them only when
383
+ the creator asks for permanent upgrades, and keep steps small (3-5%).
384
+
385
+ **Before launch** coins are play money, and play money buys only in-run
386
+ items. Looks, unlocks and meta levels need real coins, so they open at
387
+ launch; nothing bought with play money persists or carries over.
388
+
389
+ **Where it is enforced.** Prices come from the manifest, never the client.
390
+ A purchase is a coin spend (earned coins first, then the wallet) that the
391
+ room server checks against that price before the item is owned, and the
392
+ item is saved in the player's profile on the server. Spent coins recycle
393
+ to the reward pool for other players, as every coin spend does.
394
+
395
+ ## Part 3: API reference (the package root, `@netforge/engine`)
396
+
397
+ Game code imports only from the package root. Every declaration validates its input with Zod at import time and throws `GameDefinitionError` with one actionable line per problem. Ids are lower_snake_case (`/^[a-z][a-z0-9_]{0,47}$/`). Asset references are library ids such as `"style.lowpoly.ghost"`, never URLs or file paths.
398
+
399
+ ### Declarations
400
+
401
+ - `game(def: GameInput): GameDef`: the game itself, declared once and exported as the module's default. `template` is one of `TEMPLATES` (`survivor`, `arena`, `dungeon`, `racer`, `platformer`, `tower-defense`, `party`, `idle`, `cards`, `collector`); `style` is one of `STYLES` (`pixel`, `lowpoly`, `toon`, `voxel`, `ps1`, `handdrawn`). Other fields depend on the template: `places`, `players`, `field`, `arena`, `round`, `look`, `tickRate`.
402
+ - `prefab(id, def: PrefabInput): PrefabDef`: an enemy, boss, pickup or prop. `model`, `appearance`, `health`, `speed`, `radius`, `damage`, `mass`, `ai` (one of `AI_BEHAVIOURS`), `drops`, `kind`, `value`, `boss`, `feel`.
403
+ - `weapon(id, def: WeaponInput): WeaponDef`: `kind` is one of `WEAPON_KINDS` (`nova`, `bolt`, `orbit`, `blaster`, `beam`, `cone`, `chain`, `aura`, `meteor`, `boomerang`); `damage`, `cooldown`, `range`, `speed`, `count`, `spread`, `pierce`, `knockback`, `levels`, `feel`. The build checks which kinds each template accepts (the arena takes `blaster`, `beam` and `nova`).
404
+ - `upgrade(id, def: UpgradeInput): UpgradeDef`: a passive or run upgrade on one of `STAT_KINDS`, with `step` and `maxLevel`.
405
+ - `director(def: DirectorInput): DirectorDef`: the spawn director for horde games, with keyframed curves (`alive`, `rate`, `health`, `damage`) and an enemy pool.
406
+ - `system(name, setup: (ctx: SystemContext) => void): void`: game logic on the tick clock (below).
407
+ - `keeper(id, def)`, `achievement(id, def)`, `mission(id, def)`, `title(id, def)`: characters, unlocks, daily goals and end-screen titles. None of these pay coins.
408
+ - `economy(def?)`: the coin model. The default needs no declaration. `economy({ store: "cosmetics" })` sells looks only; `economy({ metaUpgrades: true })` opts in to `metaUpgrade(...)`.
409
+ - `metaUpgrade(id, { stat, step, levels, cost, growth })`: permanent stat upgrades, capped by the engine at `META_MAX_PER_STAT` per stat and `META_MAX_TOTAL` across all, at most `META_MAX_LEVELS` levels each and `META_MAX_UPGRADES` per game, with costs growing by at least `META_MIN_GROWTH`.
410
+ - `dungeon(spec: DungeonSpec): DungeonLayout`: a seeded floor plan (`seed`, `rooms`, `cell`, `size`, `corridor`, `loops`) whose `.field` goes into `game({ field })`; `.rooms`, `.roomAt(x, z)` and `.boss` feed systems.
411
+ - Board genres: `towerBoard`, `tower`, `waves` (tower defense); `saltFlat`, `plant` (idle); `marsh`, `creature`, `move` (monster collector); `cardTable`, `card` (card battler). Each template's `llms.txt` documents its own fields.
412
+
413
+ ### Earn and the store
414
+
415
+ - `earn.rule(id, { when, n?, pay, oncePer })`: pays `pay` whole coins when the event `when` (a dotted name such as `"wave.cleared"`) reaches its `n`th count; `oncePer` is `"run"`, `"day"` or `"lifetime"`. Every game declares at least one.
416
+ - `earn.stake({ min, max, multiple })`: the buy-in run, declared exactly once by every game. A staked run pays its normal earn plus a share of `stake x multiple` (multiple 1 to `STAKE_MAX_MULTIPLE`), by the share of the run's most earn the player makes.
417
+ - `earn.pot({ entry, recycle, enabled? })`: an optional PvP pot for arena games. Off unless `enabled: true`.
418
+ - `store.sku(id, { coins?, usdg?, grants? })`: a store item priced in coins (1 coin is 1 of the game's own token). No `grants` is an in-run consumable or a run upgrade named after a declared `upgrade`; `{ cosmetic: n }` is a look owned for good; `{ keeper: id }` unlocks a keeper; `{ gear: upgradeId, loseBelow }` is losable gear.
419
+ - `STAKE_REASON` (`"stake"`) and `POT_REASON` (`"pot"`) are reserved and never SKU ids.
420
+
421
+ ### The system context
422
+
423
+ `system(name, (ctx) => { ... })` receives a deliberately small, deterministic context. Systems run only on the authoritative side (the room host and the headless playtest); clients see the results in snapshots.
424
+
425
+ - Clock: `tick()`, `time()`, `every(seconds, n => ...)`, `after(seconds, fn)`, `onTick(fn)`.
426
+ - Players: `players()` returns each player's `id`, `position`, `health`, `alive`, `kills`, `deaths` and `bot`.
427
+ - Events: `on(event, ids => ...)` for the sim's own events (`enemy.killed`, `boss.killed`, `player.downed`, `player.revived`, `player.levelup`, `player.died`; arena: `opponent.killed`, `round.top`, `round.finished`; and `run.started`) and any you emit; `emit(event, ids?)` credits those players, or everyone present.
428
+ - Spawning: `spawnEdge(prefab, { count, around?, side?, spread?, scale?, elite? })` brings enemies in just off screen (prefer it); `spawn(prefab, [x, y, z], { elite? })`, `spawnRing(prefab, { count, radius, around? })`, `spawnPickup(prefab, [x, y, z])`, `scatter(prefab, count)`, `clear(prefab)`, `alive(prefab?)`, `onScreen(x, z)`.
429
+ - Pacing: `announce(text, { tone? })`, `upcoming(label, seconds)`, `bossAlive()`, `pauseDirector(seconds)`, `surge(multiplier, seconds)`, `xpBoost(multiplier, seconds)`.
430
+ - Run control: `respawnDead()`, `end("won" | "lost")`, and `random()` (the sim's seeded PRNG; never `Math.random`).
431
+
432
+ ### Playtest
433
+
434
+ `engine.test.run(opts: HarnessOptions): Promise<HarnessReport>` plays the declared game on the server simulation.
435
+
436
+ - Options: `ticks`, `bots`, and optionally `seed`, `tickRate`, `enemies` (stress), `enemyMix`, `bot` (`"basic"` or `"human"`), `picks` (`"random"` or `"greedy"`), and `assert`: a list of `{ name, check: (report) => boolean }`.
437
+ - Report: `kills`, `deaths`, `wave`, `runs` (`won`, `lost`), `maxLevel`, `peakEnemies`, `topScore`, `scorers`, `earned` (by rule id), `earnEmitted`, `msPerTick`, `p99MsPerTick`, `softlock`, `fun` (fun metrics, horde and arena games) and `rules` (the board genres' own checks).
438
+
439
+ ```ts
440
+ import { engine } from "@netforge/engine";
441
+ const r = await engine.test.run({ ticks: 30 * 180, bots: 4, seed: 3, assert: [{ name: "kills", check: (x) => x.kills > 500 }] });
442
+ ```
443
+
444
+ ### Constants and schemas
445
+
446
+ `GameDefinitionError`, `TEMPLATES`, `STYLES`, `AI_BEHAVIOURS`, `WEAPON_KINDS`, `STAT_KINDS`, `LOOK_PRESETS`, `PARTICLE_PRESETS`, `DECAL_PRESETS`, `SOUND_CUES`, `TITLE_STATS`, `META_STATS`, the `META_*` caps, `STAKE_MAX_MULTIPLE`, `STAKE_MAX_COINS`, `TOWER_KINDS`, `TOWER_STATS`, `PLANT_KINDS`, `IDLE_STATS`, `ELEMENTS`, `MOVE_EFFECTS`, `COLLECTOR_STATS`, `CARD_STATS`, `EFFECT_KINDS`, `EFFECT_TARGETS`, `APPEARANCE_PARTS`, `appearanceSchema` and `dungeonSchema`. Every input and definition type (`GameInput`, `PrefabInput`, `WeaponInput`, `EarnRuleInput`, `SkuInput` and the rest) is exported for TypeScript.
447
+
448
+ ### Other entry points (template code only)
449
+
450
+ - `@netforge/engine/runtime`: `createRenderer`, `createRenderStack`, `CameraRig`, `fieldBounds`, `InputController`, `AssetLoader`, `FeelKit`, `SoundPlayer`, `InstancedCrowd`, `ArenaClient`, `SystemScheduler`, `collectManifest`, `createStyledView`, `createPixelStyle`.
451
+ - `@netforge/engine/game`: `compileGame`, `collectManifest`, `GameRunner`, `EarnTracker`, `engineSimAdapter`, `engineSchema`, `botInput`, `HumanBot`, `Director`.
452
+ - `@netforge/engine/net`: the client netcode (prediction, reconciliation, interpolation, delta snapshots, the room client).
453
+ - `@netforge/engine/rules`: `rulesAdapter`, `playRules` and the board genres' rules modules.
454
+ - `@netforge/engine/voxel` and `@netforge/engine/earn-source`.
455
+
456
+ ### Tools for agents
457
+
458
+ The Net Forge MCP server runs with `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`. `@netforge/kit-play` runs a game folder (`netnet.json` and `src/game.ts`) locally with play money: `npx kit-play`.
459
+
460
+ Docs: https://forge.netnet.capital/docs/packages/engine
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # NetNet game engine: physics, game feel and rendering (llms.txt)
1
+ # Net Forge game engine: physics, game feel and rendering (llms.txt)
2
2
 
3
3
  > The engine every Net Forge game runs on. Two
4
4
  > layers matter here:
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
1
  {
2
2
  "name": "@netforge/engine",
3
- "version": "0.1.0",
4
- "description": "NetNet game runtime client: the typed game API the model writes against, and the three.js runtime (WebGPU with WebGL2 fallback) that plays it.",
3
+ "version": "0.2.0",
4
+ "description": "Net Forge game engine client: a typed declarative game API and a three.js WebGPU/WebGL2 renderer for multiplayer browser games.",
5
+ "keywords": [
6
+ "game-engine",
7
+ "multiplayer",
8
+ "browser-game",
9
+ "html5-game",
10
+ "threejs",
11
+ "three",
12
+ "webgpu",
13
+ "webgl",
14
+ "netcode",
15
+ "client-side-prediction",
16
+ "typescript",
17
+ "gamedev",
18
+ "ai-agents",
19
+ "mcp",
20
+ "vibe-coding",
21
+ "net-forge"
22
+ ],
5
23
  "license": "MIT",
6
24
  "type": "module",
7
25
  "main": "./dist/index.js",
@@ -34,7 +52,10 @@
34
52
  "./voxel": {
35
53
  "types": "./dist/runtime/styles/voxel/core.d.ts",
36
54
  "default": "./dist/runtime/styles/voxel/core.js"
37
- }
55
+ },
56
+ "./package.json": "./package.json",
57
+ "./llms.txt": "./llms.txt",
58
+ "./llms-full.txt": "./llms-full.txt"
38
59
  },
39
60
  "files": [
40
61
  "dist",
@@ -42,18 +63,22 @@
42
63
  "GAME-DESIGN.md",
43
64
  "llms.txt",
44
65
  "NOTICE",
45
- "LICENSE"
66
+ "LICENSE",
67
+ "llms-full.txt"
46
68
  ],
47
69
  "sideEffects": false,
48
70
  "publishConfig": {
49
71
  "access": "public"
50
72
  },
73
+ "homepage": "https://forge.netnet.capital/docs/packages/engine",
74
+ "bugs": {
75
+ "url": "https://github.com/netnetdev/net-forge/issues"
76
+ },
51
77
  "repository": {
52
78
  "type": "git",
53
79
  "url": "git+https://github.com/netnetdev/net-forge.git",
54
80
  "directory": "packages/engine"
55
81
  },
56
- "homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/engine#readme",
57
82
  "engines": {
58
83
  "node": ">=20"
59
84
  },
@@ -62,7 +87,7 @@
62
87
  "nipplejs": "1.0.4",
63
88
  "three": "0.186.1",
64
89
  "zod": "4.4.3",
65
- "@netforge/engine-sim": "0.1.0"
90
+ "@netforge/engine-sim": "0.2.0"
66
91
  },
67
92
  "devDependencies": {
68
93
  "@types/three": "0.186.0",
@@ -71,9 +96,6 @@
71
96
  "vitest": "3.2.7",
72
97
  "@types/node": "22.20.1"
73
98
  },
74
- "bugs": {
75
- "url": "https://github.com/netnetdev/net-forge/issues"
76
- },
77
99
  "scripts": {
78
100
  "build": "tsc -p tsconfig.json",
79
101
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.api.json && tsc -p tsconfig.demo.json",