@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 +90 -137
- package/dist/api/schemas.js +2 -2
- package/dist/game/earn-source.d.ts +1 -1
- package/dist/net/protocol.d.ts +7 -0
- package/dist/net/protocol.js +2 -0
- package/dist/net/room-client.d.ts +8 -0
- package/dist/net/room-client.js +13 -1
- package/llms-full.txt +460 -0
- package/llms.txt +1 -1
- package/package.json +31 -9
package/README.md
CHANGED
|
@@ -1,148 +1,101 @@
|
|
|
1
1
|
# @netforge/engine
|
|
2
2
|
|
|
3
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
Node 20 or later. The package ships ES modules with TypeScript types.
|
|
140
14
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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`.
|
package/dist/api/schemas.js
CHANGED
|
@@ -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
|
|
679
|
-
*
|
|
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
|
-
/**
|
|
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). */
|
package/dist/net/protocol.d.ts
CHANGED
|
@@ -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;
|
package/dist/net/protocol.js
CHANGED
|
@@ -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>>;
|
package/dist/net/room-client.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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.
|
|
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",
|