@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.
Files changed (245) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +100 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +215 -0
  76. package/dist/net/protocol.js +10 -0
  77. package/dist/net/room-client.d.ts +65 -0
  78. package/dist/net/room-client.js +156 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms-full.txt +460 -0
  244. package/llms.txt +183 -0
  245. 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.