@hypersoniclabs/helix-mcp 0.2.5 → 0.2.12

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 (98) hide show
  1. package/README.md +81 -11
  2. package/dist/continuumCanary.d.ts +17 -0
  3. package/dist/continuumCanary.js +17 -0
  4. package/dist/continuumCanary.js.map +1 -0
  5. package/dist/server.d.ts +14 -1
  6. package/dist/server.js +4058 -162
  7. package/dist/server.js.map +1 -1
  8. package/dist/tsconfig.build.tsbuildinfo +1 -1
  9. package/dist/vehicleTools.d.ts +86 -0
  10. package/dist/vehicleTools.js +229 -0
  11. package/dist/vehicleTools.js.map +1 -0
  12. package/docs/avatar-face.md +115 -0
  13. package/docs/bridge.md +98 -0
  14. package/docs/bring-your-world.md +117 -0
  15. package/docs/catalog.md +69 -1
  16. package/docs/character-animation.md +442 -0
  17. package/docs/character-attachments.md +166 -0
  18. package/docs/character-world.md +785 -130
  19. package/docs/continuum.md +153 -0
  20. package/docs/items.md +73 -0
  21. package/docs/lighting-world.md +667 -0
  22. package/docs/locomotion-clip-spec.md +294 -0
  23. package/docs/manifest.md +31 -6
  24. package/docs/multiplayer-logic.md +460 -17
  25. package/docs/multiplayer-templates/chrono-orchard.md +36 -22
  26. package/docs/multiplayer-templates/collect-a-thon.md +28 -38
  27. package/docs/multiplayer-templates/collections.md +24 -24
  28. package/docs/multiplayer-templates/hangout.md +124 -111
  29. package/docs/multiplayer-templates/npc-wave.md +310 -0
  30. package/docs/multiplayer-templates/obby.md +13 -16
  31. package/docs/multiplayer-templates/persistent-progress.md +218 -0
  32. package/docs/multiplayer-templates/physics-bumper.md +27 -9
  33. package/docs/multiplayer-templates/physics-football.md +22 -8
  34. package/docs/multiplayer-templates/relic-bearers.md +12 -15
  35. package/docs/multiplayer-templates/server-motion.md +16 -19
  36. package/docs/multiplayer-templates/shooter-range.md +275 -0
  37. package/docs/multiplayer-templates/team-control.md +28 -15
  38. package/docs/multiplayer-templates/turn-arena.md +31 -22
  39. package/docs/multiplayer-templates/voice-radio.md +166 -0
  40. package/docs/multiplayer-templates/wave-survival.md +7 -8
  41. package/docs/multiplayer-templates/world-shop.md +240 -0
  42. package/docs/multiplayer-world.md +222 -129
  43. package/docs/npc-world.md +623 -0
  44. package/docs/publishing.md +108 -28
  45. package/docs/purchases.md +223 -0
  46. package/docs/scene-performance.md +64 -0
  47. package/docs/screenshots.md +140 -0
  48. package/docs/sdk.md +324 -5
  49. package/docs/shooter-worlds.md +537 -0
  50. package/docs/terrain.md +173 -0
  51. package/docs/upgrades.md +324 -0
  52. package/docs/vehicles.md +727 -0
  53. package/docs/world-inspect.md +156 -0
  54. package/docs/world-look.md +241 -0
  55. package/docs/world-recipe.md +65 -6
  56. package/package.json +15 -4
  57. package/skills/README.md +91 -0
  58. package/skills/helix-assets/SKILL.md +491 -0
  59. package/skills/helix-assets/references/asset-sources.md +143 -0
  60. package/skills/helix-assets/references/vault-api.md +105 -0
  61. package/skills/helix-avatar-qa/SKILL.md +85 -0
  62. package/skills/helix-avatars/SKILL.md +206 -0
  63. package/skills/helix-avatars/references/contract.md +166 -0
  64. package/skills/helix-avatars/references/dynamics.md +367 -0
  65. package/skills/helix-avatars/references/face.md +50 -0
  66. package/skills/helix-avatars/references/publish.md +76 -0
  67. package/skills/helix-avatars/references/qa.md +251 -0
  68. package/skills/helix-avatars/references/rigging.md +88 -0
  69. package/skills/helix-avatars/references/source-generated.md +190 -0
  70. package/skills/helix-avatars/references/source-model.md +90 -0
  71. package/skills/helix-avatars/references/source-rigid.md +90 -0
  72. package/skills/helix-avatars/references/source-vrm.md +61 -0
  73. package/skills/helix-gauntlet/SKILL.md +128 -0
  74. package/skills/helix-multiplayer/SKILL.md +150 -0
  75. package/skills/helix-multiplayer/references/dsl-capability-map.md +107 -0
  76. package/skills/helix-vehicles/SKILL.md +218 -0
  77. package/skills/helix-vehicles/references/addons.md +212 -0
  78. package/skills/helix-vehicles/references/appearance.md +339 -0
  79. package/skills/helix-vehicles/references/audio-import.md +138 -0
  80. package/skills/helix-vehicles/references/audio.md +580 -0
  81. package/skills/helix-vehicles/references/cabin.md +225 -0
  82. package/skills/helix-vehicles/references/host-manifest.md +174 -0
  83. package/skills/helix-vehicles/references/physics-from-specs.md +463 -0
  84. package/skills/helix-vehicles/references/publish.md +214 -0
  85. package/skills/helix-vehicles/references/qa.md +177 -0
  86. package/skills/helix-vehicles/references/reference-package.json +3481 -0
  87. package/skills/helix-vehicles/references/reference-package.md +69 -0
  88. package/skills/helix-vehicles/references/source-beamng.md +167 -0
  89. package/skills/helix-vehicles/references/source-concept.md +38 -0
  90. package/skills/helix-vehicles/references/source-model.md +100 -0
  91. package/skills/helix-vehicles/references/source-scratch.md +60 -0
  92. package/skills/helix-world-build/SKILL.md +376 -0
  93. package/skills/helix-world-build/references/config-gates.md +104 -0
  94. package/skills/helix-world-director/SKILL.md +210 -0
  95. package/skills/helix-world-qa/SKILL.md +371 -0
  96. package/skills/helix-world-qa/references/perf-budgets.md +240 -0
  97. package/skills/helix-world-qa/references/perf-handle.md +125 -0
  98. package/skills/helix-world-qa/references/visual-scorecard.md +85 -0
@@ -0,0 +1,240 @@
1
+ # Performance budgets — what was measured, and what it does not say
2
+
3
+ Every number here came off one machine — **Apple M1 Pro, ANGLE Metal, headless Chromium,
4
+ `gl.finish()` per frame, vsync off** — running two real published worlds. Where a limit is a
5
+ guess, it says so. Where a hypothesis was refuted, the refutation is kept, because the wrong
6
+ answer is the one an agent will reach for first.
7
+
8
+ Raw evidence: `qa/perf.txt` in the Night Market and Skyward projects (ablation ladders,
9
+ before/after tables, the light-count sweep).
10
+
11
+ ## The failure this exists to catch
12
+
13
+ Night Market shipped at **25.5 ms p50 / 92.7 ms p95, 26% of frames over 33 ms** — 39 fps
14
+ median, 11 fps when the player turned around. It passed every gate we had. QA measured
15
+ triangles, bundle size and draw calls, printed frame rate as an informational line, and never
16
+ failed on it. A world could run at 10 fps and be green.
17
+
18
+ Frame time is the outcome. Triangles, draw calls and light counts are causes. Gate the outcome,
19
+ report the causes.
20
+
21
+ ## The refuted hypothesis — do not reinstate it
22
+
23
+ The obvious suspect was shadow-casting point lights: three renders the scene six times per
24
+ frame for each one, so 2 of them were 13 of the 14 render passes, 392 draw calls and 728k
25
+ triangles per frame.
26
+
27
+ **Freezing the shadow maps removed all of that and made the world zero milliseconds faster.**
28
+
29
+ ```
30
+ baseline 86.7 ms
31
+ freeze shadow maps (render once) 89.4 ms -2.7 ms saved 0%
32
+ disable shadows entirely 66.9 ms 19.8 ms saved 23%
33
+ stop point lights casting 81.4 ms 5.3 ms saved 6%
34
+ ```
35
+
36
+ Rendering the shadow maps was free. **Looking them up was not.** The entire 19.8 ms attributed
37
+ to "shadows" is per-pixel PCF sampling in the fragment shader. Shadow-map rendering cost 6% and
38
+ reads as 86% of the draw calls — which is why draw calls are a bad proxy for time.
39
+
40
+ ## The actual cause: analytic light COUNT
41
+
42
+ three.js has **no light culling**. Every light in the scene is evaluated in the fragment shader
43
+ for every lit pixel, whether or not it reaches that pixel. Twenty-one local lights cost
44
+ twenty-one lights per pixel across the whole screen.
45
+
46
+ Removing all point lights alone saved **86%** of the frame.
47
+
48
+ It is not a linear cost. It is an occupancy cliff — three unrolls every light into one fragment
49
+ shader, and past ~14 lights the register pressure collapses GPU occupancy:
50
+
51
+ | punctual lights | 0 | 2 | 4 | 6 | 8 | 10 | 12 | 14 | 16 | 20 | 24 |
52
+ |---|---|---|---|---|---|---|---|---|---|---|---|
53
+ | p50 ms | 9.2 | 9.9 | 10.7 | 11.6 | 12.8 | 14.1 | 15.6 | 18.4 | 22.7 | **47.0** | **85.2** |
54
+ | fps | 109 | 101 | 94 | 86 | 78 | 71 | 64 | 54 | 44 | **21** | **12** |
55
+ | marginal ms/light | — | 0.35 | 0.40 | 0.45 | 0.60 | 0.65 | 0.75 | 1.40 | 2.15 | **6.08** | **9.55** |
56
+
57
+ *(Night Market's material set, pixel ratio pinned to 2 = 5.18 M pixels, identical pinned pose
58
+ and deterministic 360° yaw sweep per run, one page load per count.)*
59
+
60
+ **The knee is between 12 and 16. The 21st light costs 13× the 4th.**
61
+
62
+ Spot lights, same method: a spot costs about **1.3×** a point light before the cliff and reaches
63
+ the cliff sooner (total 16 → 35.4 ms all-mixed vs 22.7 ms all-point). Which is why the gate
64
+ counts **point + spot combined** rather than per type.
65
+
66
+ ## The limits
67
+
68
+ | Limit | Value | Category |
69
+ |---|---|---|
70
+ | Frame p50 | **≤ 16.7 ms (60 fps sustained)** | blocking outcome |
71
+ | Frame p95 | **≤ 33.3 ms (30 fps)** | blocking outcome |
72
+ | Punctual lights (point + spot) | warn at **6**, fail at **12**, design target **8** | the cause |
73
+ | Shadow-casting lights | **1 directional, 0 point, ≤1 spot** | the cause |
74
+ | Shadow map size | **≤ 2048², 1024² recommended** | **memory, not time** |
75
+ | Drawing-buffer pixels | **≤ 2.6 M**, as a derived ratio | the largest multiplier |
76
+ | Draw calls | **≤ 300/frame** | CPU submission guard |
77
+ | Triangles | **≤ 500k/frame** | CPU submission guard |
78
+ | Decoded texture bytes | **≤ 256 MB** | **UNVALIDATED — warns, does not block** |
79
+
80
+ ### Punctual lights: 8 by design, 12 as a wall
81
+
82
+ The design target is 8 because it leaves **2× headroom** for GPUs weaker than an M1 Pro. The
83
+ wall is 12 because that is where the curve stops being linear — not because 13 lights is
84
+ meaningfully worse than 12, but because past it you cannot predict the cost at all.
85
+
86
+ ### Shadow casters: gate the count, not the map size
87
+
88
+ A shadow-casting **point** light renders the whole scene six times per frame. On Skyward one
89
+ brazier flame was 85 of 141 draw calls and 233k of 424k triangles — for a shadow no player could
90
+ read. Zero are allowed. One directional key light carries the shape read for the whole world.
91
+
92
+ ### Shadow map size is a MEMORY limit
93
+
94
+ 512 vs 1024 measured as **noise** (−1.3 ms, inside run-to-run variance, on the wrong side of
95
+ zero). Shrinking a shadow map returns megabytes, not milliseconds. Say this out loud whenever
96
+ you touch it, or the next agent will "optimise" it expecting frames back and conclude the
97
+ profiler is broken when nothing changes.
98
+
99
+ ### Resolution: a budget, never a fixed DPR cap
100
+
101
+ Halving the drawing buffer halved the frame — **69%**, the single largest lever measured. But it
102
+ is a multiplier over every per-pixel cost, not an independent cause: it makes a badly-lit world
103
+ cheaper without making it well-lit.
104
+
105
+ Express it as a budget and derive the ratio:
106
+
107
+ ```ts
108
+ // The largest pixel ratio (<=2, >=1) that keeps the drawing buffer under budget.
109
+ const BUDGET_PX = 2.6e6;
110
+ function pixelRatioFor(w: number, h: number, dpr = window.devicePixelRatio) {
111
+ return Math.max(1, Math.min(2, dpr, Math.sqrt(BUDGET_PX / (w * h))));
112
+ }
113
+ renderer.setPixelRatio(pixelRatioFor(innerWidth, innerHeight));
114
+ // …and recompute it in the resize handler, not just at boot.
115
+ ```
116
+
117
+ **A fixed `Math.min(devicePixelRatio, 2)` passes on the window you tested and falls off a cliff
118
+ on a bigger one.** At 1440×900 the budget resolves to ratio 1.42; at 1920×1200 it resolves to
119
+ 1.06 — same 2.6 M pixels, same frame time, a bigger window. A fixed cap of 2 would have been
120
+ 9.2 M pixels and 3.5× the per-pixel cost. `helix world perf-gate` probes for exactly this by growing the
121
+ window mid-run.
122
+
123
+ ### Draw calls and triangles: guards, not the thing that matters
124
+
125
+ Say this plainly whenever you report them. **418 draw calls and 778k triangles per frame
126
+ measured as costing nothing on this machine** — the frozen-shadow run removed 392 calls and 728k
127
+ triangles and returned no time at all. The 300 / 500k limits are deliberately generous guards on
128
+ CPU submission cost for machines weaker than an M1 Pro. A world failing only these is a world
129
+ with a CPU-side risk, not a world that is slow here.
130
+
131
+ ### Decoded texture bytes: UNVALIDATED, and therefore not blocking
132
+
133
+ 256 MB is a precaution, not a measurement. Decoded texture memory was never observed to cost
134
+ frame time on unified-memory Apple silicon; Skyward carried 252 MB at 500 fps. It is held as a
135
+ budget because a 16 GB shared-memory machine running a browser, an editor and a world has other
136
+ things to pay for. **Do not present it as measured, and never diagnose a slow world with it.**
137
+
138
+ It **warns and does not block**, and the reason is a real result rather than a preference. On
139
+ its first run this limit was blocking, and it failed Skyward at **279.7 MB while the world was
140
+ rendering at 1.9 ms p50 (526 fps)** — a gate stopping a release over a number that has never
141
+ been shown to cost anything. It also read 237 MB through the published shell and 279.7 MB
142
+ locally on the *same bundle*: ~18% run-to-run spread, which is not gate-quality precision.
143
+
144
+ A blocking gate on an unvalidated number teaches an agent to delete texture resolution chasing
145
+ frames it will not get — the same error as "optimising" a shadow map, and the same confusion
146
+ between causes and outcomes that let two worlds ship at 39 fps with everything green. **It
147
+ becomes blocking the day someone measures texture memory costing a frame, and not before.**
148
+
149
+ ## When you hit the light ceiling, do this — not "delete lights"
150
+
151
+ The ceiling is not an instruction to make the world flat. Night Market kept its atmosphere at
152
+ **6 lights** while it had 21 before, and the hero plate is indistinguishable. Two techniques did
153
+ that:
154
+
155
+ ### 1. A fixed light pool bound to emitters
156
+
157
+ Every lantern, bulb, lamp and fire becomes an **emitter** — a description of a light, not a
158
+ light. Each frame the emitters are ranked by distance to the camera and the nearest N are bound
159
+ into a fixed pool of N real lights. Shader cost is then constant no matter how many lanterns the
160
+ world grows.
161
+
162
+ ```ts
163
+ type Emitter = { pos: THREE.Vector3; color: THREE.Color; intensity: number; range: number };
164
+
165
+ const POOL = 6;
166
+ const pool = Array.from({ length: POOL }, () => {
167
+ const l = new THREE.PointLight(0xffffff, 0, 1);
168
+ l.castShadow = false; // pooled lights never cast
169
+ scene.add(l);
170
+ return l;
171
+ });
172
+
173
+ function bindPool(emitters: Emitter[], camera: THREE.Camera) {
174
+ const cam = camera.getWorldPosition(new THREE.Vector3());
175
+ const ranked = emitters
176
+ .map((e) => ({ e, d: e.pos.distanceTo(cam) }))
177
+ .sort((a, b) => a.d - b.d)
178
+ .slice(0, POOL);
179
+ pool.forEach((light, i) => {
180
+ const hit = ranked[i];
181
+ if (!hit) { light.intensity = 0; return; }
182
+ // Fade to zero over the outer 28% of the emitter's own range, so an emitter can only
183
+ // enter or leave the pool while contributing nothing. A re-bind is then invisible —
184
+ // without this, walking down a street makes lanterns pop on and off.
185
+ const fade = 1 - Math.max(0, (hit.d - hit.e.range * 0.72) / (hit.e.range * 0.28));
186
+ light.position.copy(hit.e.pos);
187
+ light.color.copy(hit.e.color);
188
+ light.distance = hit.e.range;
189
+ light.intensity = hit.e.intensity * Math.min(1, Math.max(0, fade));
190
+ });
191
+ }
192
+ ```
193
+
194
+ Bind **after** any per-frame flicker/animation writes to the emitters, or the breathing is one
195
+ frame stale.
196
+
197
+ ### 2. Instanced additive ground decals — this is what preserves the atmosphere
198
+
199
+ A lantern that is *not* currently in the pool still needs to read as lit. Give every emitter site
200
+ one instanced additive quad on the ground beneath it: a warm smear on wet asphalt, one draw call
201
+ for all of them.
202
+
203
+ ```ts
204
+ const decal = new THREE.InstancedMesh(
205
+ new THREE.PlaneGeometry(1, 1),
206
+ new THREE.MeshBasicMaterial({
207
+ map: radialFalloffTexture, transparent: true, blending: THREE.AdditiveBlending,
208
+ depthWrite: false, toneMapped: false, fog: true,
209
+ }),
210
+ emitters.length, // 21 emitter sites, ONE draw call
211
+ );
212
+ decal.renderOrder = 2; // after opaque, before other transparencies
213
+ ```
214
+
215
+ Set each instance's matrix to the emitter's ground position, rotated flat and scaled to the pool
216
+ radius, and its instance colour to the emitter colour. Unlit, so it costs nothing per light —
217
+ and it is the pooled world's entire visual difference from the 21-light one.
218
+
219
+ Other moves, in the order they cost least:
220
+ - **Emissive materials.** A glowing bulb mesh reads as a light source without being one. A
221
+ street-lamp cone rebuilt from an emissive bulb inside its glass housing replaced four spot
222
+ lights.
223
+ - **Raise ambient/hemisphere/`environmentIntensity`** to carry what the deleted always-on lights
224
+ used to. Night Market went hemisphere 0.34 → 0.46, bounce 0.40 → 0.47, environment 0.35 → 0.44.
225
+ Verify against the original hero plate, not by argument.
226
+ - **Strip lights that arrive inside loaded GLBs.** An authored prop that ships its own
227
+ `PointLight` adds a per-pixel cost to every material in the world, invisibly and outside your
228
+ budget. Remove them on placement.
229
+
230
+ ## Reading a perf-gate result
231
+
232
+ - **The renderer string is part of the result.** A frame time without the GPU that produced it is
233
+ not interpretable, and it is how you notice the ANGLE flags stopped working. If it says
234
+ SwiftShader, the run is void — the gate refuses rather than reporting it.
235
+ - **`gl.finish()` per frame is what makes the number real.** Without it the CPU queues frames
236
+ ahead of the GPU and you measure JS. Skyward read 562 fps without it.
237
+ - **p95 is the number players feel.** A world at 9 ms p50 and 90 ms p95 stutters exactly when the
238
+ player turns to look at something, which is exactly when they are paying attention.
239
+ - **One change, then re-measure the same scenario.** A batch of seven optimisations tells you
240
+ nothing about which one worked, and one of them is usually a regression.
@@ -0,0 +1,125 @@
1
+ # The `?perf=1` handle — ship it in every world
2
+
3
+ `helix world perf-gate` measures frame time, draw calls, triangles, passes, drawing-buffer pixels and
4
+ decoded texture bytes by wrapping the WebGL context, so those work on **any** bundle, including
5
+ one built before this file existed.
6
+
7
+ Two things it cannot get that way:
8
+
9
+ - **which** lights are in the scene — names, shadow flags, map sizes;
10
+ - a shadow map's configured size, as opposed to the size of some render target.
11
+
12
+ Without the handle those gates report `measured NOTHING`, which is a warning you then have to
13
+ carry into the QA report. With it, a failure names the light.
14
+
15
+ ## Why not read the source instead
16
+
17
+ We tried. `helix world source-audit` used to count `new THREE.PointLight(` across the source tree and
18
+ print `PointLight×4 · SpotLight×1`. The scene it was describing contained **18 point lights and
19
+ 4 spot lights** — four constructor call sites, all inside loops. Skyward reported 3 and had 13.
20
+
21
+ The one metric that would have caught the defect that shipped was **wrong by 4.5×**, and it was
22
+ wrong in the direction that passes. A census has to walk the scene that is actually rendering.
23
+
24
+ ## The file
25
+
26
+ Copy this to `src/perf.ts` and call `installPerfHandle` once, after the renderer and scene exist.
27
+ It installs nothing unless the query flag is present, so it costs a `URLSearchParams` read during
28
+ play.
29
+
30
+ ```ts
31
+ import type * as THREE from 'three';
32
+
33
+ /**
34
+ * Performance handle, gated behind `?perf=1`.
35
+ *
36
+ * Without this there is no way to attribute a frame to a cause: you can see that a world is
37
+ * slow, but not what fraction of the frame each light / shadow / material choice is worth. The
38
+ * handle exposes the renderer's own counters plus a light census, and lets a profiler pin a
39
+ * deterministic camera so two runs differ only by the one thing being ablated.
40
+ */
41
+ export interface PerfDeps {
42
+ THREE: typeof THREE;
43
+ scene: THREE.Scene;
44
+ renderer: THREE.WebGLRenderer;
45
+ camera: THREE.Camera;
46
+ /** Physics body, so the profiler can park the player at a fixed, repeatable vantage point. */
47
+ body?: { teleport?(p: { x: number; y: number; z: number }): void };
48
+ /** Character facade, for camera yaw/pitch pinning. */
49
+ character?: unknown;
50
+ }
51
+
52
+ export interface LightCensusRow {
53
+ type: string;
54
+ name: string;
55
+ intensity: number;
56
+ castShadow: boolean;
57
+ mapSize: [number, number] | null;
58
+ /** three renders a shadow-casting point light SIX times per frame, one per cube face. */
59
+ passesPerFrame: number;
60
+ }
61
+
62
+ export function installPerfHandle(deps: PerfDeps): void {
63
+ if (typeof location === 'undefined') return;
64
+ if (!new URLSearchParams(location.search).has('perf')) return;
65
+
66
+ const { scene, renderer } = deps;
67
+
68
+ const lights = (): LightCensusRow[] => {
69
+ const rows: LightCensusRow[] = [];
70
+ scene.traverse((o) => {
71
+ const l = o as THREE.Light & { shadow?: THREE.LightShadow; castShadow?: boolean };
72
+ if (!(l as { isLight?: boolean }).isLight) return;
73
+ const cast = l.castShadow === true;
74
+ const isPoint = l.type === 'PointLight';
75
+ rows.push({
76
+ type: l.type,
77
+ name: l.name || '(unnamed)',
78
+ intensity: l.intensity,
79
+ castShadow: cast,
80
+ mapSize: l.shadow ? [l.shadow.mapSize.x, l.shadow.mapSize.y] : null,
81
+ passesPerFrame: cast ? (isPoint ? 6 : 1) : 0,
82
+ });
83
+ });
84
+ return rows;
85
+ };
86
+
87
+ const info = () => ({
88
+ render: {
89
+ calls: renderer.info.render.calls,
90
+ triangles: renderer.info.render.triangles,
91
+ frame: renderer.info.render.frame,
92
+ },
93
+ memory: { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures },
94
+ programs: renderer.info.programs?.length ?? 0,
95
+ shadowPassesPerFrame: lights().reduce((a, r) => a + r.passesPerFrame, 0),
96
+ });
97
+
98
+ (globalThis as unknown as Record<string, unknown>).__helixPerf = {
99
+ THREE: deps.THREE, scene, renderer, camera: deps.camera,
100
+ body: deps.body, character: deps.character, lights, info,
101
+ };
102
+ }
103
+ ```
104
+
105
+ ```ts
106
+ // src/main.ts, once, after the renderer and scene exist:
107
+ installPerfHandle({ THREE, scene, renderer, camera, body: character.services.body, character });
108
+ ```
109
+
110
+ ## Name your lights
111
+
112
+ `light.name = 'lantern-pool-0'` costs nothing and turns a gate failure from *"3 point lights cast
113
+ shadows"* into *"brazier-flame, relic-halo and lamp-3 cast shadows"*. The census prints whatever
114
+ is there; `(unnamed)` is a self-inflicted wound.
115
+
116
+ ## What it unlocks beyond the gate
117
+
118
+ `__helixPerf` also exposes `scene`, `renderer`, `camera` and `body`, which is what makes an
119
+ **ablation ladder** possible: pin the player at one vantage point, run one page load per variant
120
+ changing exactly one thing, and the frame-time delta between two runs is attributable to that one
121
+ thing. That is how the light-count curve in `perf-budgets.md` was produced, and it is the only
122
+ honest way to answer "what is this frame actually spent on".
123
+
124
+ Rule for a ladder: **pin the pose and sweep the view identically in every run.** A variant that
125
+ happens to face the skybox will look like a 60% win.
@@ -0,0 +1,85 @@
1
+ # Visual scorecard
2
+
3
+ Score **active-play screenshots** at the framing a player actually gets — not an idle title
4
+ card, not an isolated model on a turntable. Desktop and a 390×844 mobile viewport.
5
+
6
+ ## Read this before you use it
7
+
8
+ This rubric is **advisory**, and saying so is the point. Four of its ten rows are backed by
9
+ a script that exits non-zero (marked ⛔); those are gates. The other six are you grading your
10
+ own screenshot, and an average you computed about your own work is not evidence. Record the
11
+ scores, use them to find the next pass, and never present the average as a gate.
12
+
13
+ The reference rubrics this one is adapted from gate entirely on self-report — an auditor
14
+ that greps the agent's prose for the string "art direction" passes any agent that types the
15
+ words. Everything here that can be measured has been moved into `helix world audit` and
16
+ `helix world source-audit` instead.
17
+
18
+ ## Scale
19
+
20
+ - **0** Placeholder. Default primitives, sparse world, unreadable state, or no evidence.
21
+ - **1** Basic styled. Playable and themed, still obvious prototype assets or flat composition.
22
+ - **2** Premium stylized. Authored silhouettes, real material/detail systems, readable state.
23
+ - **3** Showcase. Strong direction, memorable hero and world, dense authored detail.
24
+
25
+ ## Categories
26
+
27
+ | # | Category | 0 | 2 | 3 | Backed by |
28
+ |---|---|---|---|---|---|
29
+ | 1 | Art direction | no theme | theme reaches forms, materials, UI, feedback | one identity visible on every surface | — |
30
+ | 2 | Hero / player | default body, untouched | authored silhouette, state cues | memorable, expressive | — |
31
+ | 3 | Props & interactables | primitives | two+ authored forms with idle/active states | desirable and clearly valued in motion | ⛔ `composition.primitive-dominant` |
32
+ | 4 | World / environment | flat plane, empty arena | layered kit with fore/mid/background and scale cues | dense authored world that aids readability | ⛔ `composition.no-loaded-models` |
33
+ | 5 | Materials | flat colors | shared material roles, wear, trim, real maps | cohesive material language, measured texture cost | ⛔ `materials.default-plastic` |
34
+ | 6 | Lighting & render | default lights, or unreadable dark | tone mapping, exposure, key/fill/rim, contact | cinematic but readable, disciplined post | ⛔ `lighting.ambient-only`, `lighting.unmotivated`, `render.tonemapping` |
35
+ | 7 | Audio | silent | every interaction has a cue; ambience present | mixed, prioritised, spatially placed | ⛔ `audio.silent-interaction`, `audio.no-ambience` |
36
+ | 8 | VFX & motion | none, or random particles | event-driven: pickup, hit, fail, spawn | clarifies gameplay, stays performant | — |
37
+ | 9 | HUD | debug text, or under the shell chrome | genre-specific states, readable at mobile size | cohesive interface with real hierarchy | — |
38
+ | 10 | Performance evidence | none after visual changes | renderer counters + desktop and mobile screenshots | baseline/post numbers, named bottleneck, budgets | — |
39
+
40
+ Row 10's level 1 is *"seems fine"*. If that is what you have, write **1**.
41
+
42
+ The craft rows 4–6 grade — layering with scale cues, material roles/wear/trim, authored contrast —
43
+ is taught in `read_doc({ name: "world-look" })`; scoring low there is a pointer to that page, not to
44
+ more post-processing.
45
+
46
+ ## Automatic failures
47
+
48
+ Any one of these blocks a premium claim, regardless of the average. The first five are
49
+ detected by script and are not negotiable; the rest you assess from the screenshot.
50
+
51
+ - ⛔ The active screenshot is primitive-dominant, or contains zero loaded models.
52
+ - ⛔ Authored surfaces are default white `MeshStandardMaterial`.
53
+ - ⛔ Lighting is ambient-only, or a local light floats unattached to an emitter.
54
+ - ⛔ `renderer.toneMapping` is unset, or there is no environment map.
55
+ *(Both lighting rows are lane-aware: a runtime-lit world — it calls `createVisualRuntime` and
56
+ authors `public/helix.visuals.json` — satisfies ambient-only / tone mapping / environment by
57
+ construction, because the visual runtime owns all three. The unattached-local-light rule
58
+ applies on both lanes.)*
59
+ - ⛔ An interaction has no sound, or a declared cue ships no file.
60
+ - The world is mostly stretched boxes, flat planes, or a sparse arena.
61
+ - One repeated silhouette does duty for every prop or every enemy.
62
+ - Fog, darkness, bloom or particles are hiding missing geometry rather than adding mood.
63
+ - The HUD overlaps the play area, clips text, or sits under the shell's top-center chrome.
64
+ - No active-play screenshot was captured, or the world was never driven by real input.
65
+
66
+ ## Report format
67
+
68
+ ```text
69
+ Visual scorecard — <world>
70
+ 1 Art direction before X / after Y evidence:
71
+ 2 Hero / player …
72
+ 3 Props …
73
+ 4 World …
74
+ 5 Materials …
75
+ 6 Lighting & render …
76
+ 7 Audio …
77
+ 8 VFX & motion …
78
+ 9 HUD …
79
+ 10 Performance evidence …
80
+ Average (advisory):
81
+ Automatic failures remaining: <list, or none>
82
+ Scripted gates: world-audit exit N · source-audit exit N
83
+ ```
84
+
85
+ If a category is below 2, name the exact next pass instead of declaring completion.