@netforge/engine 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAME-DESIGN.md +195 -0
- package/LICENSE +21 -0
- package/NOTICE +30 -0
- package/README.md +100 -2
- package/dist/api/appearance.d.ts +37 -0
- package/dist/api/appearance.js +41 -0
- package/dist/api/cards.d.ts +105 -0
- package/dist/api/cards.js +133 -0
- package/dist/api/collector.d.ts +114 -0
- package/dist/api/collector.js +204 -0
- package/dist/api/dungeon.d.ts +58 -0
- package/dist/api/dungeon.js +216 -0
- package/dist/api/harness.d.ts +149 -0
- package/dist/api/harness.js +549 -0
- package/dist/api/idle.d.ts +71 -0
- package/dist/api/idle.js +104 -0
- package/dist/api/race-harness.d.ts +3 -0
- package/dist/api/race-harness.js +148 -0
- package/dist/api/registry.d.ts +241 -0
- package/dist/api/registry.js +394 -0
- package/dist/api/scheduler.d.ts +56 -0
- package/dist/api/scheduler.js +160 -0
- package/dist/api/schemas.d.ts +958 -0
- package/dist/api/schemas.js +859 -0
- package/dist/api/tower.d.ts +127 -0
- package/dist/api/tower.js +183 -0
- package/dist/game/adapter.d.ts +69 -0
- package/dist/game/adapter.js +341 -0
- package/dist/game/arena-bot.d.ts +66 -0
- package/dist/game/arena-bot.js +188 -0
- package/dist/game/bot.d.ts +95 -0
- package/dist/game/bot.js +285 -0
- package/dist/game/compile.d.ts +97 -0
- package/dist/game/compile.js +476 -0
- package/dist/game/director.d.ts +42 -0
- package/dist/game/director.js +179 -0
- package/dist/game/earn-source.d.ts +122 -0
- package/dist/game/earn-source.js +386 -0
- package/dist/game/earn.d.ts +49 -0
- package/dist/game/earn.js +55 -0
- package/dist/game/index.d.ts +37 -0
- package/dist/game/index.js +25 -0
- package/dist/game/platformer.d.ts +156 -0
- package/dist/game/platformer.js +619 -0
- package/dist/game/profile.d.ts +97 -0
- package/dist/game/profile.js +140 -0
- package/dist/game/race.d.ts +177 -0
- package/dist/game/race.js +386 -0
- package/dist/game/racer.d.ts +98 -0
- package/dist/game/racer.js +379 -0
- package/dist/game/runner.d.ts +286 -0
- package/dist/game/runner.js +824 -0
- package/dist/game/threats.d.ts +23 -0
- package/dist/game/threats.js +49 -0
- package/dist/game/view.d.ts +47 -0
- package/dist/game/view.js +123 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +24 -0
- package/dist/net/bits.d.ts +33 -0
- package/dist/net/bits.js +135 -0
- package/dist/net/client.d.ts +75 -0
- package/dist/net/client.js +151 -0
- package/dist/net/index.d.ts +23 -0
- package/dist/net/index.js +23 -0
- package/dist/net/input.d.ts +49 -0
- package/dist/net/input.js +112 -0
- package/dist/net/interpolation.d.ts +37 -0
- package/dist/net/interpolation.js +97 -0
- package/dist/net/netsim.d.ts +22 -0
- package/dist/net/netsim.js +52 -0
- package/dist/net/prediction.d.ts +31 -0
- package/dist/net/prediction.js +101 -0
- package/dist/net/profile-client.d.ts +75 -0
- package/dist/net/profile-client.js +173 -0
- package/dist/net/protocol.d.ts +215 -0
- package/dist/net/protocol.js +10 -0
- package/dist/net/room-client.d.ts +65 -0
- package/dist/net/room-client.js +156 -0
- package/dist/net/schema.d.ts +78 -0
- package/dist/net/schema.js +121 -0
- package/dist/net/sim-adapter.d.ts +134 -0
- package/dist/net/sim-adapter.js +22 -0
- package/dist/net/snapshot.d.ts +61 -0
- package/dist/net/snapshot.js +301 -0
- package/dist/rules/adapter.d.ts +53 -0
- package/dist/rules/adapter.js +261 -0
- package/dist/rules/cards.d.ts +115 -0
- package/dist/rules/cards.js +547 -0
- package/dist/rules/collector.d.ts +161 -0
- package/dist/rules/collector.js +732 -0
- package/dist/rules/genres.d.ts +13 -0
- package/dist/rules/genres.js +38 -0
- package/dist/rules/harness.d.ts +16 -0
- package/dist/rules/harness.js +127 -0
- package/dist/rules/idle.d.ts +115 -0
- package/dist/rules/idle.js +370 -0
- package/dist/rules/index.d.ts +16 -0
- package/dist/rules/index.js +14 -0
- package/dist/rules/kit.d.ts +171 -0
- package/dist/rules/kit.js +88 -0
- package/dist/rules/tower.d.ts +214 -0
- package/dist/rules/tower.js +655 -0
- package/dist/runtime/arena.d.ts +86 -0
- package/dist/runtime/arena.js +269 -0
- package/dist/runtime/assets.d.ts +26 -0
- package/dist/runtime/assets.js +35 -0
- package/dist/runtime/camera.d.ts +113 -0
- package/dist/runtime/camera.js +247 -0
- package/dist/runtime/crowd.d.ts +18 -0
- package/dist/runtime/crowd.js +62 -0
- package/dist/runtime/feel/audio.d.ts +150 -0
- package/dist/runtime/feel/audio.js +327 -0
- package/dist/runtime/feel/clock.d.ts +52 -0
- package/dist/runtime/feel/clock.js +80 -0
- package/dist/runtime/feel/flash.d.ts +28 -0
- package/dist/runtime/feel/flash.js +60 -0
- package/dist/runtime/feel/index.d.ts +27 -0
- package/dist/runtime/feel/index.js +16 -0
- package/dist/runtime/feel/kit.d.ts +159 -0
- package/dist/runtime/feel/kit.js +226 -0
- package/dist/runtime/feel/knockback.d.ts +41 -0
- package/dist/runtime/feel/knockback.js +70 -0
- package/dist/runtime/feel/noise.d.ts +6 -0
- package/dist/runtime/feel/noise.js +30 -0
- package/dist/runtime/feel/numbers.d.ts +91 -0
- package/dist/runtime/feel/numbers.js +172 -0
- package/dist/runtime/feel/rumble.d.ts +40 -0
- package/dist/runtime/feel/rumble.js +58 -0
- package/dist/runtime/feel/sound.d.ts +96 -0
- package/dist/runtime/feel/sound.js +176 -0
- package/dist/runtime/feel/spring.d.ts +42 -0
- package/dist/runtime/feel/spring.js +95 -0
- package/dist/runtime/feel/trauma.d.ts +48 -0
- package/dist/runtime/feel/trauma.js +76 -0
- package/dist/runtime/feel/tween.d.ts +66 -0
- package/dist/runtime/feel/tween.js +180 -0
- package/dist/runtime/feel/types.d.ts +30 -0
- package/dist/runtime/feel/types.js +5 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.js +26 -0
- package/dist/runtime/input.d.ts +38 -0
- package/dist/runtime/input.js +159 -0
- package/dist/runtime/interpolate.d.ts +6 -0
- package/dist/runtime/interpolate.js +13 -0
- package/dist/runtime/render/animation.d.ts +63 -0
- package/dist/runtime/render/animation.js +128 -0
- package/dist/runtime/render/atmosphere.d.ts +32 -0
- package/dist/runtime/render/atmosphere.js +59 -0
- package/dist/runtime/render/crowd-motion.d.ts +68 -0
- package/dist/runtime/render/crowd-motion.js +163 -0
- package/dist/runtime/render/decals.d.ts +64 -0
- package/dist/runtime/render/decals.js +222 -0
- package/dist/runtime/render/highlight.d.ts +29 -0
- package/dist/runtime/render/highlight.js +110 -0
- package/dist/runtime/render/index.d.ts +35 -0
- package/dist/runtime/render/index.js +22 -0
- package/dist/runtime/render/lighting.d.ts +50 -0
- package/dist/runtime/render/lighting.js +142 -0
- package/dist/runtime/render/particles.d.ts +117 -0
- package/dist/runtime/render/particles.js +289 -0
- package/dist/runtime/render/post.d.ts +92 -0
- package/dist/runtime/render/post.js +191 -0
- package/dist/runtime/render/presets.d.ts +57 -0
- package/dist/runtime/render/presets.js +116 -0
- package/dist/runtime/render/quality.d.ts +113 -0
- package/dist/runtime/render/quality.js +180 -0
- package/dist/runtime/render/rig.d.ts +29 -0
- package/dist/runtime/render/rig.js +107 -0
- package/dist/runtime/render/sprite.d.ts +34 -0
- package/dist/runtime/render/sprite.js +58 -0
- package/dist/runtime/render/stack.d.ts +74 -0
- package/dist/runtime/render/stack.js +132 -0
- package/dist/runtime/render/vat.d.ts +78 -0
- package/dist/runtime/render/vat.js +219 -0
- package/dist/runtime/renderer.d.ts +19 -0
- package/dist/runtime/renderer.js +18 -0
- package/dist/runtime/stats.d.ts +11 -0
- package/dist/runtime/stats.js +29 -0
- package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
- package/dist/runtime/styles/handdrawn/ground.js +282 -0
- package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
- package/dist/runtime/styles/handdrawn/index.js +94 -0
- package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
- package/dist/runtime/styles/handdrawn/ink.js +68 -0
- package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
- package/dist/runtime/styles/handdrawn/looks.js +42 -0
- package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
- package/dist/runtime/styles/handdrawn/paint.js +129 -0
- package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
- package/dist/runtime/styles/handdrawn/palette.js +70 -0
- package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
- package/dist/runtime/styles/handdrawn/textures.js +150 -0
- package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
- package/dist/runtime/styles/handdrawn/vfx.js +73 -0
- package/dist/runtime/styles/index.d.ts +9 -0
- package/dist/runtime/styles/index.js +32 -0
- package/dist/runtime/styles/kit.d.ts +193 -0
- package/dist/runtime/styles/kit.js +1 -0
- package/dist/runtime/styles/pixel/index.d.ts +46 -0
- package/dist/runtime/styles/pixel/index.js +167 -0
- package/dist/runtime/styles/ps1/index.d.ts +29 -0
- package/dist/runtime/styles/ps1/index.js +41 -0
- package/dist/runtime/styles/ps1/lights.d.ts +47 -0
- package/dist/runtime/styles/ps1/lights.js +146 -0
- package/dist/runtime/styles/ps1/materials.d.ts +62 -0
- package/dist/runtime/styles/ps1/materials.js +285 -0
- package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
- package/dist/runtime/styles/ps1/pipeline.js +111 -0
- package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
- package/dist/runtime/styles/ps1/surfaces.js +228 -0
- package/dist/runtime/styles/toon/index.d.ts +5 -0
- package/dist/runtime/styles/toon/index.js +178 -0
- package/dist/runtime/styles/toon/ink.d.ts +39 -0
- package/dist/runtime/styles/toon/ink.js +73 -0
- package/dist/runtime/styles/toon/look.d.ts +10 -0
- package/dist/runtime/styles/toon/look.js +112 -0
- package/dist/runtime/styles/toon/material.d.ts +74 -0
- package/dist/runtime/styles/toon/material.js +208 -0
- package/dist/runtime/styles/toon/normals.d.ts +26 -0
- package/dist/runtime/styles/toon/normals.js +127 -0
- package/dist/runtime/styles/toon/shadows.d.ts +24 -0
- package/dist/runtime/styles/toon/shadows.js +42 -0
- package/dist/runtime/styles/toon/vfx.d.ts +15 -0
- package/dist/runtime/styles/toon/vfx.js +47 -0
- package/dist/runtime/styles/view.d.ts +35 -0
- package/dist/runtime/styles/view.js +34 -0
- package/dist/runtime/styles/voxel/core.d.ts +114 -0
- package/dist/runtime/styles/voxel/core.js +577 -0
- package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
- package/dist/runtime/styles/voxel/geometry.js +40 -0
- package/dist/runtime/styles/voxel/index.d.ts +21 -0
- package/dist/runtime/styles/voxel/index.js +23 -0
- package/dist/runtime/styles/voxel/kit.d.ts +6 -0
- package/dist/runtime/styles/voxel/kit.js +11 -0
- package/dist/runtime/styles/voxel/look.d.ts +10 -0
- package/dist/runtime/styles/voxel/look.js +16 -0
- package/dist/runtime/styles/voxel/props.d.ts +9 -0
- package/dist/runtime/styles/voxel/props.js +52 -0
- package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
- package/dist/runtime/styles/voxel/shapes.js +83 -0
- package/dist/runtime/styles/voxel/world.d.ts +49 -0
- package/dist/runtime/styles/voxel/world.js +422 -0
- package/llms-full.txt +460 -0
- package/llms.txt +183 -0
- package/package.json +104 -3
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
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Net Forge game engine: physics, game feel and rendering (llms.txt)
|
|
2
|
+
|
|
3
|
+
> The engine every Net Forge game runs on. Two
|
|
4
|
+
> layers matter here:
|
|
5
|
+
> 1. Game code (src/game.ts) only DECLARES how things look and feel, with
|
|
6
|
+
> `look` on game() and `feel` on prefab() and weapon(). Templates draw it.
|
|
7
|
+
> 2. Template code (client/ and the sim) uses the runtime APIs below:
|
|
8
|
+
> the physics world in @netforge/engine-sim, and the feel kit and
|
|
9
|
+
> render stack in @netforge/engine/runtime.
|
|
10
|
+
> Generated game code never imports the runtime. It cannot reach the
|
|
11
|
+
> renderer, netcode or physics, and it does not need to.
|
|
12
|
+
|
|
13
|
+
## 1. Game code: look and feel declarations
|
|
14
|
+
|
|
15
|
+
Everything here is presentation. It never changes the simulation, so it
|
|
16
|
+
is always safe to tune. Unknown values fail the build with a message.
|
|
17
|
+
|
|
18
|
+
### The look of the whole game (game({ look }))
|
|
19
|
+
|
|
20
|
+
game({
|
|
21
|
+
template: "survivor", style: "lowpoly", places: { ... }, players: { ... },
|
|
22
|
+
look: { preset: "dusk", bloom: 1.3, fog: 0.4, vignette: 0.4, tint: "#ffd9a0" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
- preset: "night" | "dusk" | "day" | "studio". Sets the sun, sky, fog and
|
|
26
|
+
colour grade together. Default "night".
|
|
27
|
+
- bloom: glow on bright and emissive things, 0 (off) to 3. Default 1.
|
|
28
|
+
- shadows: sun shadows on or off (on by default above the low quality tier).
|
|
29
|
+
- outline: a toon outline around shapes (true or false).
|
|
30
|
+
- fog: 0 (clear) to 1 (thick).
|
|
31
|
+
- exposure (0.2 to 3), saturation (0 to 2), contrast (0.5 to 1.5),
|
|
32
|
+
vignette (0 to 1), tint ("#rrggbb", a grading colour over the frame).
|
|
33
|
+
|
|
34
|
+
Slow devices drop shadows, post and resolution by themselves (quality
|
|
35
|
+
tiers and dynamic resolution), so turning things on never breaks 60 fps.
|
|
36
|
+
|
|
37
|
+
### How a prefab dies and takes hits (prefab({ feel }))
|
|
38
|
+
|
|
39
|
+
prefab("brute", { model: "style.lowpoly.brute", health: 160, speed: 2.2, radius: 0.8, mass: 6,
|
|
40
|
+
feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
|
|
41
|
+
|
|
42
|
+
### How a weapon's impacts look (weapon({ feel }))
|
|
43
|
+
|
|
44
|
+
weapon("frost_ring", { kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
|
|
45
|
+
feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
|
|
46
|
+
|
|
47
|
+
feel fields, all optional:
|
|
48
|
+
- shake: screen shake added, 0 to 1 (0.2 a bump, 0.6 a big hit).
|
|
49
|
+
- hitStop: seconds the picture freezes on impact (0.03 to 0.08 feels good, 0.2 max).
|
|
50
|
+
- particles: "sparks" | "burst" | "embers" | "smoke" | "hitSpark" | "dust" | "magic".
|
|
51
|
+
- decal: "none" | "scorch" | "splat" | "ring" (left on the ground, fades).
|
|
52
|
+
- sound: "none" | "hit" | "crunch" | "boom" | "zap" | "chime" | "thud".
|
|
53
|
+
- color: "#rrggbb" for particles, decals and flashes.
|
|
54
|
+
- flash: false turns off the white hit flash.
|
|
55
|
+
|
|
56
|
+
### Knockback is physics
|
|
57
|
+
|
|
58
|
+
weapon knockback is metres per second pushed into each enemy, divided by
|
|
59
|
+
the enemy's mass. Enemies knocked hard stagger, slide, bounce off pillars
|
|
60
|
+
and shove the enemies behind them. mass 1 is light, 6 is a brute, 20 a
|
|
61
|
+
boss (light hits barely move it).
|
|
62
|
+
|
|
63
|
+
### Dungeon levels (game({ field }) with walls and floors)
|
|
64
|
+
|
|
65
|
+
const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
|
|
66
|
+
game({ template: "dungeon", ..., field: CRYPT.field });
|
|
67
|
+
|
|
68
|
+
dungeon(...) grows a floor plan from a seed: rooms on a grid, joined by
|
|
69
|
+
corridors, the start room centred on [0, 0] and the deepest room as
|
|
70
|
+
CRYPT.boss. field.walls are boxes [x, z, halfX, halfZ] that stop players,
|
|
71
|
+
enemies and bolts; field.floors are the only ground (spawns land on them,
|
|
72
|
+
and enemies behind a wall path round it through the doors on a flow
|
|
73
|
+
field). Systems get onScreen(x, z) to bring enemies in where nobody is
|
|
74
|
+
looking. A system's room logic reads CRYPT.rooms and CRYPT.roomAt(x, z).
|
|
75
|
+
|
|
76
|
+
## 2. Template code: the physics world (@netforge/engine-sim)
|
|
77
|
+
|
|
78
|
+
Deterministic Rapier on client and server. No rendering dependency.
|
|
79
|
+
|
|
80
|
+
import { initSim, createPhysics, restorePhysics, Layer } from "@netforge/engine-sim";
|
|
81
|
+
await initSim();
|
|
82
|
+
const p = createPhysics({ gravity: [0, -20, 0], tickRate: 30 });
|
|
83
|
+
p.addStaticBox([30, 0.5, 30], [0, -0.5, 0], { layer: Layer.Level });
|
|
84
|
+
const crate = p.addBody({ type: "dynamic", shape: { box: [0.4, 0.4, 0.4] }, position: [0, 2, 0],
|
|
85
|
+
mass: 20, friction: 0.7, restitution: 0.2, layer: Layer.Prop });
|
|
86
|
+
const bolt = p.addBody({ type: "dynamic", shape: { sphere: 0.1 }, ccd: true, gravityScale: 0,
|
|
87
|
+
linearVelocity: [80, 0, 0], layer: Layer.Projectile, mask: Layer.Level | Layer.Enemy });
|
|
88
|
+
const zone = p.addTrigger({ box: [2, 2, 2] }, [5, 1, 0]);
|
|
89
|
+
const hero = p.addCharacter({ position: [0, 1, 0], maxSpeed: 7, jumpVelocity: 8, coyoteTicks: 5,
|
|
90
|
+
jumpBufferTicks: 5, airAccel: 18, dashTicks: 6, dashSpeed: 22, dashCooldownTicks: 30 });
|
|
91
|
+
const { events, characters } = p.step(new Map([[hero, { moveX: 1, moveZ: 0, jump: true, dash: false }]]));
|
|
92
|
+
|
|
93
|
+
- Bodies: dynamic, kinematic (setKinematicTarget) or static; box, sphere,
|
|
94
|
+
capsule, cylinder; mass or density, friction, restitution, damping,
|
|
95
|
+
gravityScale, lockRotations, sensor, ccd, userData (a NetId).
|
|
96
|
+
- Layers: Layer.Level, Player, Enemy, Projectile, Pickup, Prop, Trigger,
|
|
97
|
+
Debris, User1..8. A pair collides only when each mask holds the other.
|
|
98
|
+
- Events from step(): contact.start, contact.end, trigger.enter,
|
|
99
|
+
trigger.exit, sorted, with a < b.
|
|
100
|
+
- Forces: applyImpulse, applyForce (one step), applyKnockback(id, from,
|
|
101
|
+
strength, { lift, byMass }). Knockback works on characters too.
|
|
102
|
+
- Queries: raycast(origin, dir, maxDist, { mask, exclude, includeSensors })
|
|
103
|
+
for line of sight and hitscan; raycastAll; shapeCast; overlap.
|
|
104
|
+
- Joints: addJoint({ kind: "fixed" | "ball" | "hinge" | "slider", a, b }).
|
|
105
|
+
- Character motor: slopes (climb and slide), steps, ground snapping, coyote
|
|
106
|
+
time, jump buffering, variable jump height, ground and air acceleration,
|
|
107
|
+
terminal velocity, dash with buffer, cooldown and invulnerability,
|
|
108
|
+
pushing dynamic bodies. Per-step reports: grounded, landed, jumped,
|
|
109
|
+
dashed, hitCeiling, groundNormal. characterState / setCharacterState for
|
|
110
|
+
reconcile.
|
|
111
|
+
- Netcode: snapshot() and restorePhysics(bytes) restore bit-exact;
|
|
112
|
+
stateHash() hashes every pose, velocity and motor state.
|
|
113
|
+
- Rapier 0.21 already stops small fast spheres on thin walls; use round
|
|
114
|
+
projectiles (thin spinning planks can still tunnel).
|
|
115
|
+
|
|
116
|
+
## 3. Template code: game feel (@netforge/engine/runtime)
|
|
117
|
+
|
|
118
|
+
import { FeelKit } from "@netforge/engine/runtime";
|
|
119
|
+
const feel = new FeelKit({ preset: "default" }); // "subtle" | "default" | "heavy"
|
|
120
|
+
feel.camera = rig; feel.project = (x, y, z) => scene.project(x, y, z); feel.particles = stack.particles;
|
|
121
|
+
// per frame
|
|
122
|
+
const { now, dt, realDt } = feel.tick(performance.now()); // hit-stop aware render clock
|
|
123
|
+
feel.hit({ id, x, y, z, amount, big, dirX, dirZ }); // flash, knockback, number, sound, shake
|
|
124
|
+
feel.kill({ x, y, z, big, color }); // particles, shake, hit-stop when big
|
|
125
|
+
feel.hurt(damage, maxHealth); // shake, stop, rumble, hurtPulse
|
|
126
|
+
feel.levelUp(); feel.dash(); feel.land(0.6); feel.footstep(speed);
|
|
127
|
+
feel.update(dt, realDt);
|
|
128
|
+
|
|
129
|
+
Parts, each usable alone: Trauma (seeded trauma shake), FeelClock (hit-stop,
|
|
130
|
+
slow motion, smooth catch-up), HitFlash, KnockbackOffsets (spring offsets
|
|
131
|
+
and squash), Spring and Spring3, DamageNumbers (pooled, merges rapid hits),
|
|
132
|
+
Tweens and Ease with pop / shakeEl / countUp for UI, FeelAudio (procedural
|
|
133
|
+
cues, throttles, voice limits, pan, distance) and Footsteps, Rumble
|
|
134
|
+
(Gamepad dual-rumble, navigator.vibrate fallback). CameraRig adds
|
|
135
|
+
addTrauma, punch (FOV kick), a smoothed and clamped lead and a dead zone.
|
|
136
|
+
|
|
137
|
+
Real sound (SoundPlayer): CC0 samples for the cues and one music loop from
|
|
138
|
+
the asset library's audio section. Samples replace the synth recipes under
|
|
139
|
+
the same cue names (the synth plays until a sample loads, or if it fails).
|
|
140
|
+
|
|
141
|
+
import { soundBank } from "@netforge/asset-library";
|
|
142
|
+
const sound = new SoundPlayer(feel.audio, { key: "mygame:sound", musicVolume: 0.35 });
|
|
143
|
+
sound.load(soundBank(libraryBase)); // cues: hit impact kill bigKill hurt levelUp gem coin dash step land boss ui pick downed revive shoot
|
|
144
|
+
sound.toggle(); // the mute (effects and music), remembered; returns true when muted
|
|
145
|
+
feel.audio.play("boss"); // any cue by name
|
|
146
|
+
|
|
147
|
+
Camera bounds: never show the void past the field.
|
|
148
|
+
rig.setBounds(fieldBounds(half, 16)); // the field plus 16 m of dressed outside ground
|
|
149
|
+
The clamp moves the camera by the overshoot of what it sees (groundFootprint,
|
|
150
|
+
boundsShift); dress that outside strip (ground, trees, fog).
|
|
151
|
+
|
|
152
|
+
Hero highlight: an outline and a rim on one character (the local hero), as
|
|
153
|
+
extra draws that share its geometry and skeleton; its materials stay as
|
|
154
|
+
they are.
|
|
155
|
+
const hl = new HeroHighlight(character.root, { rim: 0xffd27a, outline: 0x140a02, outlineWidth: 0.05 });
|
|
156
|
+
hl.strength = 0.4; hl.dispose();
|
|
157
|
+
|
|
158
|
+
## 4. Template code: rendering (@netforge/engine/runtime)
|
|
159
|
+
|
|
160
|
+
const stack = createRenderStack(handle, scene, rig.camera, { preset: "night", quality: "auto", bloom: true });
|
|
161
|
+
stack.frame(dt, focus); // instead of renderer.render(scene, camera)
|
|
162
|
+
stack.particles.burst("bigKill", x, y, z, { color: 0xffc070 });
|
|
163
|
+
stack.decals.add("scorch", x, z, { size: 3 });
|
|
164
|
+
stack.rings.fire(x, z, 6, { color: 0xffb347 });
|
|
165
|
+
stack.post.hurt.value = feel.hurtPulse;
|
|
166
|
+
|
|
167
|
+
- LightingRig: hemisphere, sun shadow following the focus with texel
|
|
168
|
+
snapping, presets.
|
|
169
|
+
- PostFX: bloom, grade (contrast, saturation, split tone, vignette, hurt
|
|
170
|
+
pulse), tone mapping once, FXAA, optional depth outline.
|
|
171
|
+
- Atmosphere: fog (linear or exp2, height fog) and sky gradient. A high
|
|
172
|
+
top-down camera should set heightFog 0.
|
|
173
|
+
- QualityManager / DynamicResolution: tiers low, medium and high
|
|
174
|
+
(auto-detected), pixel ratio held to the frame budget.
|
|
175
|
+
- GpuParticles: one draw for every preset; motion runs on the GPU.
|
|
176
|
+
- GroundDecals and GroundRings: pooled, one draw each.
|
|
177
|
+
- AnimatedCrowd: procedural vertex animation for instanced crowds (bob,
|
|
178
|
+
waddle, lean, hit squash, flash). VatCrowd with bakeVertexAnimation:
|
|
179
|
+
skinned clips baked to a texture, thousands of instances in one draw.
|
|
180
|
+
AnimatedCharacter: skinned glTF with locomotion blending.
|
|
181
|
+
- WebGPU pipelines build on first use. Draw one of everything out of sight
|
|
182
|
+
at load (EMBERWATCH's SurvivorScene.warmup) so the first kill does not
|
|
183
|
+
hitch.
|