diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md
new file mode 100644
index 0000000000..ec20f75f4e
--- /dev/null
+++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-872-the-lint-names-the-engine-call.md
@@ -0,0 +1,123 @@
+# PRD-872 — The lint names the engine call
+
+**Status:** IN PROGRESS (2026-11-10) — phases 1 and 2 landed on `absorb-2026-11-10`; phase 3's engine half landed and its sandbox-game refactor is open because those games are a separate checkout
+**Priority:** P1 — the kit lint does not check entity `update` methods and rejects no reinvention, so the three newest games each rebuilt shipped capabilities
+**Complexity:** 4 (MEDIUM); risk override: none
+**Owner:** ThreeNative maintainers
+**Depends on:** None
+**Batch:** [Absorb report, 2026-11-10](README.md)
+
+Merged `origin/develop` (`b01de92c7`) on 2026-11-12: the only conflict was `starter`'s scaffold pin, recomputed from the merged tree; `pnpm typecheck`, `ci:fast`, `capabilities:check`, `api:surface:check` and the focused suites (quality-rules 18, scaffold 79, rain-quality 10, rts-runtime-cost 8, state 9, engine-mcp search 108) pass on the merge.
+
+## Context
+
+An agent that does not search the manifest writes the capability again. PRD-355 plans a detector for
+that. This PRD takes the cheaper route that already runs in each game: `pnpm lint`.
+
+| Problem | Where | Checked by |
+| --- | --- | --- |
+| The hot-callback rules track `update` and `render` only on a `Scene` subclass, plus 6 hooks. Entity classes are not checked. | `packages/create-threenative/src/quality/hotCallbacks.ts:4-15`, `:73-75` | lead |
+| 22 per-frame allocation or tree-walk sites in entity `update` methods across about 12 kits. Example: `new Vector2` each tick. | `templates/starter/src/render/touch-controls.ts:118`; `adventure/src/entities/LockOn.ts:104,121` | surveyor (syntactic scan) |
+| 18 kit files carry their own PRNG; 7 use `createRandom`. `starlance` has 5 PRNG copies and 6 `Math.random` calls. | `rg '1664525\|0x6d2b79f5' templates` | lead |
+| Damping written as `1 - Math.exp(-k * dt)`: 21 sites in 16 kit files, 10 in `starlance`. `MathUtils.damp` has 9 uses. | `rg '1 - Math\.exp\(-' templates` | lead |
+| Inline smoothstep `t * t * (3 - 2 * t)`: 38 sites in 26 kit files. Local `clamp`, `smooth`, `lerp` definitions: 32 in 21 files. | `rg '\* \(3 - 2 \* ' templates` | lead |
+| Launch seams read from `location.search` by regex: 10 sites in `starlance` and `stress-lab`, 4 in kits. They are off on native. `readLaunchValue` is not exported. | `packages/core/src/debug.ts:96`; `starlance/src/scenes/Play.ts:200-205` | lead (export), surveyor (sites) |
+| `midway-open-pacific` sets `scene.matrixWorldAutoUpdate = false` and walks matrices itself (96 lines). `MatrixWorldPass` is the default. | `midway-open-pacific/src/scenes/Midway.ts:295-310`, `src/render/world.ts:1261-1312` | surveyor |
+| Axes built from raw keys, 5 times. `InputMap` has `axis`. | `midway-open-pacific/src/scenes/Midway.ts:495-519` | surveyor |
+| The manifest has 0 rows for `ctx.assets.memo` and `ctx.startup.progress`. `ctx.beforeRender` is not a symbol. `starlance` rebuilds a 9-second stage on each load. | `rg -c 'assets\.memo' packages/core/capabilities.json` | lead |
+
+## Solution
+
+1. **Widen the hot-callback tracker.** `trackHotCallbacks` also treats a non-static class method named
+ `update`, `fixedUpdate`, `tick` or `step` as per-frame. The 3 existing hot-callback rules then apply
+ with no change.
+2. **Add one data table of reinventions.** `packages/create-threenative/src/quality/reinventions.ts`
+ exports rows `{ selector, capability, message }`. `qualityConfig` feeds them to the ESLint built-in
+ `no-restricted-syntax`. No new rule code. A spec checks that each `capability` is a symbol in
+ `packages/core/capabilities.json`, so a message cannot name a removed symbol.
+3. **First rows** (each has 3 or more measured sites):
+
+ | Pattern | Message names |
+ | --- | --- |
+ | `Math.random()`, or the literals `1664525`, `1013904223`, `0x6d2b79f5`, outside `src/ui/` and `src/audio/` | `createRandom`, `ctx.random` |
+ | `1 - Math.exp(-…)` | `MathUtils.damp` |
+ | `location.search` or `new URLSearchParams(location…)` | `debugFlag`, `debugValue` |
+ | `….matrixWorldAutoUpdate = false` | config `renderer.matrixWorld` |
+ | `(keys.has(a) ? 1 : 0) - (keys.has(b) ? 1 : 0)` | `ctx.input.axis` |
+ | `x * x * (3 - 2 * x)` outside TSL | `MathUtils.smoothstep` |
+
+ Amended 2026-11-10, after review: the PRNG row also excludes `src/render/**`. A kit's render
+ sources may not import a core symbol outside `RENDER_PORTABLE_CORE_SYMBOLS`
+ (`scripts/check-core-boundary.ts`), so they keep their own generators; the row still covers a
+ level, an entity, a scene and a sim. The smoothstep and damp rows name `three`, which every
+ render source already imports, so those stay everywhere.
+
+4. **Export `debugValue(name)`.** It wraps `readLaunchValue` with the `debugFlag` naming rule
+ (`?name` on web, `TN_DEBUG_NAME` on native).
+5. **Add manifest rows** for `ctx.assets.memo`, `ctx.startup.progress`, `ctx.after`, `ctx.beforeRender`
+ and `debugValue`, each with the situations an author searches for.
+
+```mermaid
+flowchart LR
+ Src["game src/**/*.ts"] --> Lint["pnpm lint"]
+ Lint --> Hot["hot-callback rules
Scene + entity update methods"]
+ Lint --> Table["reinventions.ts rows
no-restricted-syntax"]
+ Table --> Msg["error names the symbol
and its import path"]
+ Manifest["capabilities.json"] --> Spec["spec: each row names a live symbol"]
+ Spec --> Table
+```
+
+Risks:
+
+- **False positives on `update`.** A class method named `update` that does not run each frame is
+ flagged. Record the count on the 16 kits and the 3 games before the rule ships. More than 5 false
+ positives means the tracker also requires a `dt` first parameter.
+- **A message that points nowhere.** The spec in point 2 closes it.
+
+Not in scope: the detector of PRD-355, capability search ranking (PRD-865), autofix.
+
+## Acceptance Criteria
+
+- [x] AC-1 [local]: A generated game that allocates in an entity `update` method, or reinvents one of the 6 rows, fails `pnpm lint` with a message that names the engine symbol. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 17 passed. Each of the 6 rows fails its fixture, passes the fixed form, and names a symbol that resolves in `capabilities.json` (or in `three`); `src/ui/**` keeps the platform RNG and still gets the smoothstep row.
+- [ ] AC-2 [local]: `starlance`, `midway-open-pacific` and `stress-lab` pass `pnpm lint` under the new rules. proof: `pnpm lint` exit 0 in each game, with the removed line count beside this box — OPEN: the three games are a separate checkout (`/home/joao/projects/threenative/sandbox`), so their refactor cannot land in this repository's PR. Engine-side proof is green; the game-side pass is not claimed.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| Widened hot-callback tracker | `pnpm lint` in a generated game → `eslint.config.js` → `qualityConfig` → `threenativePlugin` | Extends `trackHotCallbacks`; no rule replaced | AC-1 |
+| Reinvention rows | Same path → `no-restricted-syntax` | New | AC-1 |
+| `debugValue` | Game import from `@threenative/core` | Replaces hand-written `location.search` parsing in kits and games | Phase 2 box |
+| Manifest rows for `ctx` members | `engine_search_capabilities` in `threenative-engine-mcp` | New rows; no row removed | Phase 3 box |
+
+## Execution Phases
+
+#### Phase 1: The hot-callback rules see entity `update` methods
+
+**Status:** DONE (2026-11-10)
+**Files:** `packages/create-threenative/src/quality/hotCallbacks.ts`, `__tests__/quality-rules.spec.ts`, the kit sources that the widened rule flags.
+**Implementation:** Add the method-name branch beside the `Scene` branch at `hotCallbacks.ts:73-75`. Fix each flagged kit site with a module-level scratch object. Do not add a suppression.
+**Verification:** the spec is red before the tracker change and green after it.
+
+- [x] An entity class whose `update` method calls `new Vector2()` fails `threenative/hot-callback-allocation`. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 14 passed at this phase (red before the tracker change: the entity `update` returned no finding). `update`, `fixedUpdate`, `tick` and `step` are tracked; a static method and a method named otherwise stay quiet. The widened rule found 12 real sites in 9 kits, 0 false positives, so the `dt`-parameter narrowing the PRD held in reserve was not needed.
+- [x] The 16 kits pass the widened rules with no suppression. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — the new "should pass every kit's own source under the hot rules and the rows" case lints all 16 `templates/*/src` trees with the three hot rules and the six rows at `error` and expects `[]` (the hot rules with no suppression; two described suppressions remain for the authored mulberry32 streams the rows still see) (it fails closed under 300 discovered files; ~3.8 s). Fixes: 9 `touch-controls.ts` files reuse one `knobPosition`, `Warden.update` reuses `toHero`, `LockOn.update` caches the target's mesh list and the reticle's own box instead of `setFromObject` each frame. `scripts/check-template-quality.ts` checks the quality presets, not ESLint — quoting it here was wrong.
+
+#### Phase 2: One table rejects the six reinventions
+
+**Status:** DONE engine-side (2026-11-10); the kit playtest run is recorded on the second box
+**Files:** `packages/create-threenative/src/quality/reinventions.ts` (new), `src/quality/index.ts`, `packages/core/src/debug.ts`, `packages/core/src/index.ts`, `__tests__/quality-rules.spec.ts`, the kit sources that the rows flag.
+**Implementation:** One row per pattern, with one failing fixture and one passing fixture each. Replace each kit site with the named call. Amended after review: the `src/render/clouds.ts` PRNG copies are **not** changed — a render source may not import a core symbol outside `RENDER_PORTABLE_CORE_SYMBOLS`, so the row excludes `src/render/**` and those files keep their own generator. No canonical render source moved.
+**Verification:** the spec fails when a row's `capability` is not in `capabilities.json`.
+
+- [x] Each row fails its fixture, and each row's `capability` is a manifest symbol. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts` — 17 passed. `scripts/../src/quality/reinventions.ts` is data only; `qualityConfig` feeds it to `no-restricted-syntax`. The spec resolves each `capability` against `packages/core/capabilities.json` (`createRandom`, `debugFlag`, `renderer.matrixWorld`, `InputMap.axis`) or against `three` (`MathUtils.damp`, `MathUtils.smoothstep`), and proves the `src/ui/**` / `src/audio*` exclusions keep only the intended rows.
+- [ ] The 16 kits pass the rows, and the touched kits pass their playtests. proof: `pnpm exec vitest run packages/create-threenative/__tests__/quality-rules.spec.ts && pnpm test:templates` — the kit-wide spec is green (18 tests), with two suppressions left, one per authored mulberry32 stream outside `src/render/` (adventure's level, rts's replay world); kart's course and rain's storm are `src/render/` and now need none. The render PRNG edits were reverted after review: a kit's `src/render/` may not import `createRandom` (`pnpm exec tsx scripts/check-core-boundary.ts` now passes; it failed on 13 render files before). Measured by `git grep` before and after: 38 inline smoothstep expressions became `MathUtils.smoothstep` calls, 24 `MathUtils.damp` calls replace 22 hand-written exponential factors (one local `damp` wrapper had three call sites), 3 launch seams became `debugFlag`/`debugValue`, and 3 hand-written LCGs outside `src/render/` became `createRandom`; 3 suppressions remain: two authored mulberry32 streams and one sim module that must not import `three`. `pnpm test:templates` was run after rebuilding the web engine (`node scripts/build-wasm.mjs`) and **failed for environment reasons, not game reasons**: 8 kits report `TN_PLAYTEST_PERF_FEW_GPU_SAMPLES` (software adapter), 6 templates' managed dev servers miss their 15 s readiness window while cooking KTX2 assets, and 2 report `TN_PLAYTEST_ENGINE_COST_OVER_BOUND` under software. A fresh `shooter` scaffold passes its own gate (`pnpm typecheck`, `pnpm lint` with the new rules, `biome format`), so the template is not the failure; its own `pnpm test` then crashes the playtest worker under the software adapter (`RangeError: Offset is outside the bounds of the DataView`). On the branch with the GPU idle (RTX 2080, Node 20, `TN_PLAYTEST_ALLOW_SOFTWARE` unset) the sweep goes much further: `adventure`, `collectathon`, `kart`, `puzzle` and `snow` pass their scaffolded suites, and the kit contract is green except for rows that also fail on `origin/develop` in the same environment (`platformer`'s sight line, `shooter`'s worker `RangeError`) or that are frame-cost rows under a CPU load of 9-13 from other agents on this machine (`no-hitch` few-GPU-samples, `engine-cost` over bound). `action-rpg restart-resets` failed once under that load and passes when run alone. The box stays open: no green sweep has been recorded, and the repo runs this sweep in the schedule-only hardware lane, not on the PR board.
+
+#### Phase 3: The missing `ctx` members are searchable, and the sandbox games adopt the rules
+
+**Status:** IN PROGRESS (2026-11-10) — the manifest rows and `debugValue` landed; the sandbox refactor is open (separate checkout)
+**Files:** the JSDoc of `memo`, `startup.progress`, `after`, `beforeRender` in `packages/core/src/`, `packages/engine-mcp/__tests__/search.spec.ts`; in `sandbox/`: the flagged sources of the 3 games.
+**Implementation:** The manifest is built from JSDoc by `pnpm build`; add the tags, do not edit `capabilities.json` by hand. In the games, replace each flagged site with the named call. `starlance` wraps its stage build in `ctx.assets.memo` and deletes the `#delayed` list for `ctx.after`. `midway-open-pacific` deletes its matrix walk and declares its axes in `defineGame({ input })`.
+**Verification:** search queries return the new rows; each game passes its lint and its smoke playtest.
+
+- [x] The queries "cache generated geometry between page loads", "boot progress bar" and "run a callback after a delay" return `ctx.assets.memo`, `ctx.startup.progress` and `ctx.after` in the first 3 rows. proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts` — 108 passed, including the four ctx queries (`ctx.beforeRender` too). Interface-member JSDoc alone is not indexed by `scripts/build-capability-manifest.ts`, so the four rows live in `scripts/ctx-capabilities.ts`, the same hand-authored registry pattern the render-chain config keys already use; `debugValue` is a real export and is indexed from its JSDoc. `pnpm capabilities:check` reports 571 fresh entries.
+- [ ] The 3 sandbox games pass `pnpm lint`, `starlance` passes `playtests/smoke.playtest.json` and `stage-clear.playtest.json`, and `midway-open-pacific` passes `playtests/flight.playtest.json`. proof: `pnpm lint` and `threenative-playtest --scenario --browser-recipe webgpu --headed` in each game — OPEN: those games are a separate repository checkout, not part of this diff. Not run, not claimed.
diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md
new file mode 100644
index 0000000000..b2861c4726
--- /dev/null
+++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md
@@ -0,0 +1,118 @@
+# PRD-873 — The engine owns the pool and the body's rest
+
+**Status:** IN PROGRESS (2026-11-10) — the store's changed-only publication landed on `absorb-2026-11-10`; `ObjectPool`, the body rest options and the `prewarm` restore are open
+**Priority:** P2 — three games and three kits hand-write object pools, two games work around a `RigidBody3D` with no damping, and three games work around `prewarm` (all phases open)
+**Complexity:** 7 (HIGH); risk override: the pool changes the draw path, so the visual-judge rule applies
+**Owner:** ThreeNative maintainers
+**Depends on:** None
+**Batch:** [Absorb report, 2026-11-10](README.md)
+
+## Context
+
+| Problem | Where | Checked by |
+| --- | --- | --- |
+| 8 object pools in one game, about 390 lines. Each scans for a free slot with `find((i) => !i.visible)` and wires `ctx.add`, `ctx.interpolate` and warm-up by hand. | `starlance/src/render/shotFx.ts:117-302`, `src/entities/Enemies.ts:276-349`, `Weapons.ts:295-334`, `Ship.ts:783-836`, `src/render/fx.ts:342-421` | surveyor; lead confirmed 5 scans |
+| The same keyed entity-to-view loop 3 times, about 230 lines: a `Map`, a new `Set` each frame, a removal loop, a build budget. | `midway-open-pacific/src/render/world.ts:1499-1589`, `1907-1944`, `2128-2265` | surveyor |
+| Pools in 3 kits. | `templates/kart/src/scenes/Race.ts:268-285`, `tower-defense/src/scenes/Battle.ts:140-190`, `shooter/src/render/pooled-billboards.ts` (144 lines) | surveyor |
+| About 200 sparks and shards are one `Mesh` each, so one draw each. | `starlance/src/render/shotFx.ts:396-434` | surveyor; draw count not measured |
+| The manifest has 2 pools and both are specific: `TracerPool3D`, `CastPool`. | `packages/core/capabilities.json` | lead |
+| `RigidBody3D` has no damping, friction, restitution, angular velocity, sleep flag or point impulse. | `packages/physics/src/RigidBody3D.ts` (0 hits) | lead |
+| Two games worked around it. `starlance` applies a drag force each tick and infers rest from mesh motion. | `starlance/FRICTION.md` 2026-11-05; `starlance/src/entities/Towers.ts:84-120`; `fluid-lab/FRICTION.md:34` | lead (starlance log), surveyor |
+| `prewarm` on a live root leaves cloned materials at opacity 0. Nothing throws. Three games hit it or wrote their own warm-up. | `packages/core/src/renderer.ts:129-155`; `fps-framework/FRICTION.md:10`; `starlance/src/entities/Enemies.ts:246-319`; `midway-open-pacific/src/render/world.ts:918-1203` | surveyor |
+| `state.set` marks the store dirty on every call, so `flush` publishes unchanged values. 5 kits hand-write a changed-only check. | `packages/core/src/state.ts:48-53`; `templates/action-rpg/src/scenes/Play.ts:583-611`, `tower-defense/src/scenes/Battle.ts:521-559` | lead (store), surveyor (kits) |
+
+## Solution
+
+1. **`ObjectPool` in `@threenative/core`.** `new ObjectPool({ make, reset, size, onFull })` with
+ `acquire()`, `release(item)` and `forEachLive(fn)`. A free list replaces the scan. The pool adds its
+ members to the scene, registers interpolation, and warms each distinct member through `warmUnculled`.
+2. **Instanced by default.** When `make` returns members that share one geometry and one material, the
+ pool draws them as one instanced batch. `instanced: false` is the named override, and the pool reports
+ it. The game supplies geometry, material, colour and timing (root `AGENTS.md` rule 3).
+3. **`RigidBody3D` rest options.** Options `linearDamping`, `angularDamping`, `friction`, `restitution`.
+ Members `angularVelocity` (read and write), `sleeping` (read) and `applyImpulseAtPoint`. Each maps to
+ the Rapier call of the same meaning. The native host gets the same options in the same commit.
+4. **`prewarm` cannot hide a live scene.** `prewarm` restores each material it changed when the compile
+ settles. A root that the engine cannot restore throws `TN_PREWARM_LIVE_SCENE` and names `warmUnculled`.
+5. **`state.set` skips unchanged keys.** A key whose new value is `Object.is`-equal to the current value
+ does not enter `pending`. `flush` publishes nothing when nothing changed.
+
+```mermaid
+flowchart LR
+ Game["game: new ObjectPool({ make, reset, size })"] --> Pool["ObjectPool
free list, lifetime, warm-up"]
+ Pool -->|"members share geometry + material"| Inst["one instanced batch"]
+ Pool -->|"instanced: false (reported)"| Sep["separate objects"]
+ Pool --> Warm["warmUnculled"]
+ Game -->|"geometry, material, colour, timing"| Pool
+```
+
+Reused: `warmUnculled`, `InstancedBatch`, `ctx.interpolate`, the Rapier body API. Check
+`engine_search_capabilities` for `pool`, `recycle` and `instanced` before you write `ObjectPool`.
+
+Risks:
+
+- **Kill switch.** `ObjectPool` must cost less code at the call site than the scan it replaces.
+ `scripts/count-loc.ts` scores it on `starlance`.
+- **Instancing changes the draw path.** Per-member material state (opacity, colour) moves to instance
+ attributes. A member that needs its own material uses `instanced: false`.
+- **Store equality hides a mutated object.** A game that mutates an object in place and sets it again
+ no longer publishes. The error text for that case names a new object or a version field.
+
+## Validation scene
+
+Proposed, not run.
+
+| Item | Pool | Store |
+| --- | --- | --- |
+| Consumer | `starlance`, `playtests/tower-break.playtest.json` (shards, flashes, rings) | `action-rpg` kit, idle HUD for 600 ticks |
+| Baseline and candidate | Same commit, `instanced: false` against the default, paired runs | Same commit, hand check removed, store skip off and on |
+| Small control | A pool of 4 members: charges the free list and the instance buffer | A state that changes each tick: charges the equality check |
+| Hardware | Local RTX 2080, headed, `--browser-recipe webgpu`, `adapter.info` checked | Same |
+| Pass bar | Fewer draws, and presented p95 not worse than run noise on the control | 0 publishes while idle, and frame time not worse than run noise on the control |
+
+## Acceptance Criteria
+
+- [ ] AC-1 [local]: `starlance` and `midway-open-pacific` run on `ObjectPool` with no hand-written pool left, and their playtests pass. proof: `rg -c 'find\(\([a-z]+\) => !' src` returns 0 in `starlance`, and `threenative-playtest --scenario playtests/tower-break.playtest.json --scenario playtests/smoke.playtest.json --browser-recipe webgpu --headed` passes — Evidence: pending.
+- [ ] AC-2 [local]: The validation scene shows a net whole-frame gain on the pool workload and no loss on the small control. proof: playtest `perf` on `tower-break`, paired arms, numbers beside this box — Evidence: pending.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| `ObjectPool` | Game import from `@threenative/core`; `shooter` kit `pooled-billboards.ts`; `starlance` `shotFx.ts` | Hand-written pools are deleted in the kits and the 2 games | AC-1 |
+| `RigidBody3D` rest options | `new RigidBody3D({ linearDamping })` in `starlance/src/entities/Towers.ts` | The drag-force workaround is deleted | Phase 2 box |
+| `prewarm` restore | `prewarm(root)` in any game | `starlance` hand warm-up (`Enemies.ts:246-319`) is deleted | Phase 2 box |
+| `state.set` skip | `ctx.state.set` in each kit scene | Changed-only checks are deleted in 5 kits | Phase 2 box |
+
+## Execution Phases
+
+#### Phase 1: `ObjectPool`, instanced by default
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/object-pool.ts` (new), `packages/core/src/index.ts`, `packages/core/__tests__/object-pool.spec.ts` (new), `templates/shooter/src/render/pooled-billboards.ts`, `templates/kart/src/scenes/Race.ts`, `templates/tower-defense/src/scenes/Battle.ts`, one playtest scenario.
+**Implementation:** Free list; `onFull: "recycle-oldest" | "drop"`; a bad `size` throws. Add a `reinventions.ts` row for the `find(!visible)` scan when PRD-872 Phase 2 has landed.
+**Verification:** unit spec for the lifetime contract; a `shooter` playtest scenario for the frame loop.
+
+- [ ] `acquire`, `release`, `onFull` and `forEachLive` hold their contract, and a pool of members that share geometry and material issues one draw. proof: `pnpm exec vitest run packages/core/__tests__/object-pool.spec.ts`
+- [ ] The `shooter`, `kart` and `tower-defense` kits use `ObjectPool` and pass their playtests. proof: `TN_TEMPLATE_ONLY=shooter,kart,tower-defense pnpm test:templates`
+- [ ] The visual judge returns NEUTRAL or better on the `shooter` kit, same pose before and after. proof: judge verdict recorded on this box
+
+#### Phase 2: A body rests, a warm-up restores, an unchanged state stays quiet
+
+**Status:** IN PROGRESS (2026-11-10) — the store skip landed; the body options and the `prewarm` restore are open
+**Files:** `packages/physics/src/RigidBody3D.ts`, `packages/physics/__tests__/rigidbody.spec.ts`, `packages/core/src/renderer.ts`, `packages/core/src/state.ts`, `packages/core/__tests__/state.spec.ts`, the native host body options, the 4 kit scenes that carried a changed-only check (`starter`, `action-rpg`, `tower-defense`, `runner` — the survey counted 5, a grep finds 4).
+**Implementation:** Each body option is validated (a negative damping throws). Native proof ships in the same commit as the option (root `AGENTS.md` rule 5).
+**Verification:** red-green unit specs; one desktop-target playtest for the body options.
+
+- [ ] A body with `angularDamping` stops and reports `sleeping`, on web and on the desktop target. proof: `pnpm exec vitest run packages/physics/__tests__/rigidbody.spec.ts` and one `--target desktop` playtest scenario
+- [ ] `prewarm` on a live root leaves each material at its authored opacity, or throws `TN_PREWARM_LIVE_SCENE`. proof: `pnpm exec vitest run packages/core/__tests__/prewarm.spec.ts`
+- [ ] `state.set` with unchanged values publishes nothing, and the kits pass with their hand checks deleted. proof: `pnpm exec vitest run packages/core/__tests__/state.spec.ts && TN_TEMPLATE_ONLY=starter,action-rpg,tower-defense,runner pnpm test:templates` — engine proof green: `state.spec.ts` has 3 new cases (600 unchanged frames publish 0 times; one changed key publishes once; the same object mutated and set again publishes nothing), red on the previous store. The four hand-written checks are deleted. The playtest arm is not green: `pnpm test:templates` fails this machine's software adapter (`TN_PLAYTEST_PERF_FEW_GPU_SAMPLES`, 15 s dev-server readiness while cooking KTX2), not the store.
+
+#### Phase 3: The sandbox games adopt it
+
+**Status:** NOT STARTED
+**Files:** in `sandbox/starlance`: `src/render/shotFx.ts`, `src/render/fx.ts`, `src/entities/{Enemies,Weapons,Ship,Towers}.ts`. In `sandbox/midway-open-pacific`: `src/render/world.ts`, `src/render/particles.ts`.
+**Implementation:** Reinstall from the branch tarballs. `starlance`: 8 pools to `ObjectPool`, `Towers.ts` drag workaround to `linearDamping` and `sleeping`, hand warm-up deleted. `midway-open-pacific`: the 3 view loops to `ObjectPool`. Record the removed lines per game beside AC-1. A fresh Opus 5.5 subagent at high effort reviews each game diff.
+**Verification:** AC-1 and AC-2.
+
+- [ ] The visual judge returns NEUTRAL or better on `starlance` (`tower-break`, `laser`) and `midway-open-pacific` (`flight`), same pose before and after. proof: judge verdict recorded on this box
diff --git a/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md b/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md
new file mode 100644
index 0000000000..723294d88f
--- /dev/null
+++ b/docs/PRDs/authoring/absorb-2026-11-10/PRD-874-kit-plumbing-leaves-the-generated-game.md
@@ -0,0 +1,106 @@
+# PRD-874 — Kit plumbing leaves the generated game
+
+**Status:** NOT STARTED
+**Priority:** P2 — a new game starts with about 850 lines of mechanism that no game should edit, and 17 kit files pass facts that the engine can measure (all phases open)
+**Complexity:** 9 (HIGH); risk override: the moved files draw the loading cover and assign materials, so the visual-judge rule applies
+**Owner:** ThreeNative maintainers
+**Depends on:** None
+**Batch:** [Absorb report, 2026-11-10](README.md)
+
+## Context
+
+PRD-833 stamps shared kit sources from one copy. That removed the drift. The lines still ship in each
+generated game, where an agent reads them and can edit them.
+
+| Problem | Where | Checked by |
+| --- | --- | --- |
+| The loading cover is about 300 lines in 14 kits. The per-kit look is one marked block of 14 to 21 lines. The rest is layout, progress, texture attach and teardown. | `templates/starter/src/render/loading.ts:14-27` (look), `:29-325` (mechanism); canonical `template-assets/loading.ts` | surveyor; lead confirmed 16 files, 12 variants |
+| On-screen touch controls: 1 976 lines in 12 kits, plus 276 in `shooter`. Each entity then merges touch into its keyboard input by hand. `InputMap` has no on-screen stick. | `templates/*/src/render/touch-controls.ts`; `starter/src/entities/Player.ts:126,139-140` | lead (line count, 13 wiring files), surveyor (split) |
+| Each kit passes measured facts to the quality tier: `mobile: isMobile()` at 35 sites in 17 files, `softwareAdapter !== undefined` at 50 sites, `gpuClass?.class` at 33 sites. 4 kits also read `globalThis.__THREENATIVE_PROFILE__`. | `templates/*/src/render/postprocessing.ts`; `packages/core/src/look/quality.ts:49-51` | lead |
+| Restart, pause and resume are hand-wired in 12 kits, with `paused` and `uiReady` fields in 14 `state.ts` files. | `templates/starter/src/game.ts:48-59`, `src/scenes/Play.ts:399-404` | lead (12 kits), surveyor |
+| Material assignment and material lighting: 3 614 lines in 12 kits. The look enters through one convert function and one options object. | `templates/starter/src/render/materialAssignments.ts:82`, `materialLighting.ts:28` | surveyor |
+
+Root `AGENTS.md` puts "plumbing every game repeats" in `packages/core/src/` and the look in the kit's
+`src/render/`. Each row above mixes the two in one file.
+
+## Solution
+
+Each mechanism moves to `@threenative/core`. Each kit keeps one short file that holds only its look and
+one call.
+
+| Mechanism | Engine symbol | The kit keeps |
+| --- | --- | --- |
+| Loading cover | `createLoadingScreen(ctx, appearance)` | The appearance block: colours, images, bar geometry, `showStatus` |
+| Touch overlay | `TouchOverlay(ctx.input, { stick, buttons, style })`, which writes into `InputMap` actions | Radii, positions and colours |
+| Quality-tier inputs | `qualityTiers.resolve(ctx)` reads platform, software adapter, GPU class and the hosted profile | `quality.ts` presets; `tier` stays the named override and is reported |
+| Restart and pause | `defineGame({ flow: { restart, pause } })`; the engine publishes `paused` and `uiReady` | The scene name and the initial state |
+| Material assignment | `createMaterialAssignments(scene, convert)` and `createMaterialLighting(options)` in `@threenative/core/render/environment` | `backlightMaterial.ts`, `backlightControls.ts` and the convention values |
+
+```mermaid
+flowchart LR
+ Kit["kit src/render/*.ts
look values only"] -->|"appearance, style, convert"| Core["@threenative/core
createLoadingScreen, TouchOverlay,
qualityTiers.resolve, flow, createMaterialAssignments"]
+ Core --> Host["ctx: canvasLayer, startup, input, renderer facts"]
+ Touch["touch pointers"] --> Core
+ Core -->|"InputMap actions"| Entity["entity reads ctx.input only"]
+```
+
+Consumer flow: a kit scene calls the engine symbol once in `enter`. An entity reads `ctx.input.vector`
+and `ctx.input.justPressed` and has no touch branch.
+
+Risks:
+
+- **The engine owns a look by accident.** Each symbol takes each colour, size and position from the
+ kit. A layout that the options cannot express stays possible: the kit draws its own cover on
+ `ctx.canvasLayer`. The spec renders one cover with every option changed.
+- **Web-only.** The touch overlay and the loading cover must run on the native host. Each ships a
+ native playtest in the same commit.
+- **Scaffold hashes.** Each move re-pins the `shared` and `kit` hashes with `pnpm scaffold:pin`.
+
+Not in scope: height fog and sky plumbing (rows L5 and L6 of the batch README), the kit contract (PRD-749).
+
+## Acceptance Criteria
+
+- [ ] AC-1 [local]: A fresh `starter` scaffold holds at least 800 fewer source lines than at 86ba395ab (9 606 lines), and passes its golden playtest. proof: `find src -name '*.ts*' | xargs cat | wc -l` in a fresh scaffold, and `TN_GOLDEN_PATH_TEMPLATES=starter pnpm verify:golden-path` — Evidence: pending.
+- [ ] AC-2 [local]: `stress-lab` and `starlance` run on the engine symbols with their kit plumbing files deleted, and pass their playtests. proof: `pnpm lint` and `threenative-playtest --scenario "playtests/*.playtest.json" --browser-recipe webgpu --headed` in each game, removed line count beside this box — Evidence: pending.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| `createLoadingScreen` | Kit `src/render/loading.ts` → scene boot | The mechanism in `template-assets/loading.ts` is deleted; the stamp of the appearance block stays | Phase 1 box |
+| `qualityTiers.resolve(ctx)` | Kit `src/render/postprocessing.ts` → `setupPost` | The fact arguments are deleted in 17 files | Phase 1 box |
+| `TouchOverlay` | Kit scene `enter` on a touch device | 12 `touch-controls.ts` files shrink to style values; entity touch branches are deleted | Phase 2 box |
+| `defineGame({ flow })` | Kit `src/game.ts` | The `onIntent` restart and pause blocks are deleted in 12 kits | Phase 2 box |
+| `createMaterialAssignments` | Kit scene `enter` | 2 files per kit shrink to one call | Phase 3 box |
+
+## Execution Phases
+
+#### Phase 1: The engine draws the cover and measures the tier inputs
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/loading-screen.ts` (new), `packages/core/src/look/quality.ts`, `packages/core/src/index.ts`, `packages/create-threenative/src/index.ts` (stamp), `template-assets/loading.ts`, the 16 kit `loading.ts` and `postprocessing.ts` files, `__tests__/scaffold*.spec.ts`.
+**Implementation:** Move `loading.ts:29-325` behind `createLoadingScreen`. `qualityTiers.resolve` accepts `ctx` and keeps its argument form for one release with a deprecation row in the manifest. Each kit prints the same `TN_QUALITY_TIER` before and after.
+**Verification:** core spec with each appearance option changed; template playtests; the judge.
+
+- [ ] Each kit's `loading.ts` holds its appearance block and one `createLoadingScreen` call, and the 16 kits pass their playtests. proof: `pnpm exec vitest run packages/core/__tests__/loading-screen.spec.ts && pnpm test:templates`
+- [ ] No kit passes `isMobile()`, `softwareAdapter`, `gpuClass` or `__THREENATIVE_PROFILE__` to the tier. proof: `rg -c 'mobile: isMobile\(\)|softwareAdapter !== undefined|__THREENATIVE_PROFILE__' packages/create-threenative/templates` returns 0, and `pnpm exec vitest run packages/core/__tests__/quality-tiers.spec.ts` (new spec; none covers `qualityTiers` today)
+- [ ] The visual judge returns NEUTRAL or better on the loading cover and the first settled frame of `starter`, `racing` and `snow`. proof: judge verdict recorded on this box
+
+#### Phase 2: Touch writes into the input map, and the game flow is declared
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/touch-overlay.ts` (new), `packages/core/src/input*.ts`, `packages/core/src/game.ts` (flow), the 13 kit touch files, the 12 kit `game.ts` files, the kit entities with a touch branch.
+**Implementation:** `TouchOverlay` maps a stick to an `InputMap` vector and a button to an action. `flow.restart` resets the store to the declared initial state, flushes, and re-enters the scene.
+**Verification:** core specs; a touch playtest on web and on the Android emulator; a restart playtest.
+
+- [ ] A touch drag moves the `starter` player through `ctx.input.vector("move")`, on web and on the Android emulator. proof: one playtest scenario with touch pointers, run with `--browser-recipe webgpu` and with `--target android`
+- [ ] Restart and pause work in 12 kits with no `onIntent` block in `game.ts`. proof: `rg -c 'intent === "restart"' packages/create-threenative/templates` returns 0, and `pnpm test:templates`
+
+#### Phase 3: Material assignment moves, and the sandbox games adopt it
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/render/environment/` (2 new files), the 12 kit `materialAssignments.ts` and `materialLighting.ts` files; in `sandbox/`: `stress-lab/src/render/`, `starlance/src/render/`.
+**Implementation:** Move the enroll, ref-count and restore code. The kit passes `backlightMaterial` as `convert`. Reinstall the 2 games from the branch tarballs and delete their copies of the moved files. A fresh Opus 5.5 subagent at high effort reviews each game diff.
+**Verification:** the judge on 3 kits; AC-1 and AC-2.
+
+- [ ] The visual judge returns NEUTRAL or better on `starter`, `racing` and `snow` after the material move, same pose before and after. proof: judge verdict recorded on this box
diff --git a/docs/PRDs/authoring/absorb-2026-11-10/README.md b/docs/PRDs/authoring/absorb-2026-11-10/README.md
new file mode 100644
index 0000000000..d8e86f6f01
--- /dev/null
+++ b/docs/PRDs/authoring/absorb-2026-11-10/README.md
@@ -0,0 +1,107 @@
+# Absorb report, 2026-11-10
+
+Date: 2026-11-10. Snapshot: local `develop` 86ba395ab. No code changed.
+
+Owner, 2026-11-10: "look for abstractions opportunity that would make dev life easier, gain
+performance optimizations by default, or have anything thats a net positive overall", then "lint rules
+to avoid anti patterns", then "goal: DRY, save on tokens, high performance by default (devs dont even
+need to know the heavy lifting its being done)", then "we'll have to refactor the sandbox games to use
+these new abstractions, and opus 5.5 high will have to review also".
+
+## Method
+
+Four read-only Sonnet surveyors, one area each. The lead merged the reports and re-ran the checks marked
+"lead" below. Everything else is a surveyor claim: re-check it before you build on it.
+
+| Surveyor | Area | Size read |
+| --- | --- | --- |
+| A | `sandbox/starlance` (rail shooter, `minimal` kit) and `sandbox/stress-lab` (`starter` kit) | 11 523 new lines, 3 226 changed lines |
+| B | `sandbox/midway-open-pacific` (carrier flight game) | 27 594 lines |
+| C | The 16 kits in `packages/create-threenative/templates/` | 108 179 source lines |
+| D | 9 sandbox `FRICTION.md` files; `starlance` and `midway-open-pacific` logs went to A and B | 843 lines in 11 files |
+
+Excluded because a PRD already owns it: kit base and kit contract (PRD-749), draw-call and hitch defaults
+(PRD-748), frame-order and collider seams (PRD-325), stamped shared render sources (PRD-833, done), the
+reinvention detector (PRD-355), capability search (PRD-298, PRD-300, PRD-865).
+
+## Findings
+
+Three patterns hold across the three newest games and the 16 kits.
+
+1. **A shipped capability is rebuilt by hand because the author does not see it.**
+ `midway-open-pacific` rebuilt `MatrixWorldPass`, `FrameBudget` with `DebugOverlay`, `mergeByMaterial`
+ and `InputMap` axes, about 350 lines. `starlance` rebuilt `ctx.after`, `createRandom` and
+ `ctx.assets.memo`, and rebuilds a 9-second stage on each page load. Lead: the manifest has 0 rows for
+ `assets.memo` and `startup.progress`, and 18 kit files carry their own PRNG while 7 use `createRandom`.
+2. **A mechanism that every game needs is missing, so each game writes it.** Object pools: 8 copies in
+ `starlance` (about 390 lines), 3 keyed view loops in `midway-open-pacific` (about 230 lines), 3 kits.
+ `RigidBody3D` has no damping, friction, restitution, angular velocity or sleep (lead: 0 hits in
+ `packages/physics/src/RigidBody3D.ts`; 2 games logged it).
+3. **Kit plumbing that no game should edit ships as game source.** Loading cover (about 300 lines in
+ 14 kits), on-screen touch controls (1 976 lines in 12 kits, lead), material assignment and lighting
+ (3 614 lines in 12 kits), and quality-tier inputs that the engine can measure (lead: `mobile:
+ isMobile()` at 35 sites in 17 files). A new game starts with these lines in its context.
+
+The kit lint cannot see most of it. Lead: `hotCallbacks.ts:73-75` tracks `update` and `render` only on a
+`Scene` subclass, so entity `update` methods are not checked.
+
+## PRDs
+
+| PRD | Goal | Priority |
+| --- | --- | --- |
+| [PRD-872](PRD-872-the-lint-names-the-engine-call.md) | The kit lint sees every per-frame method and names the engine call for each reinvention. Missing `ctx` members become manifest rows. | P1 |
+| [PRD-873](PRD-873-the-engine-owns-the-pool-and-the-bodys-rest.md) | `ObjectPool`, `RigidBody3D` rest options, a `prewarm` that cannot hide a live scene, and a state store that skips unchanged keys. | P2 |
+| [PRD-874](PRD-874-kit-plumbing-leaves-the-generated-game.md) | Loading cover, touch overlay, restart and pause flow, quality-tier inputs and material assignment move to the engine with every appearance value passed in. | P2 |
+
+Order: PRD-872 first (cheapest, guards the other two), then PRD-873, then PRD-874. No PRD blocks another.
+
+## Rules for every PRD here
+
+1. **Search first.** Run `engine_search_capabilities` for each new symbol before you write it. A row in
+ this report that names "none found" is a surveyor claim.
+2. **Mechanism only.** A new engine symbol takes every colour, size, count and timing from the game
+ (root `AGENTS.md` rule 3). A value the engine can measure is not an option.
+3. **The sandbox games adopt it.** Owner, 2026-11-10. Each PRD refactors `starlance`,
+ `midway-open-pacific` and `stress-lab` to the new symbols in its last phase. Reinstall the packages
+ from the branch tarballs, the way `stress-lab` does (`sandbox/.packages/*.tgz`). Do not patch a
+ sandbox copy of the engine.
+4. **Opus 5.5 at high effort reviews.** Owner, 2026-11-10. A fresh Opus 5.5 subagent at high effort
+ reviews each phase diff and each sandbox refactor. The verdict goes in the PR body, not in a box.
+5. **Visual judge.** A change that alters what reaches the screen needs the judge from root `AGENTS.md`.
+ The verdict goes on the box that claims the outcome.
+
+## Later: ranked rows with no PRD yet
+
+Each row gets its own PRD when it is picked. Effort: S under 1 hour, M half a day, L 1 to 3 days.
+
+| # | Row | Evidence | Effort |
+| --- | --- | --- | --- |
+| L1 | Scene-warning pass names a material that reads a missing attribute, and a scene where every surface is transparent | 4 games (`grove`, `fps-framework`, `fps-vanilla`, `wildwood`); `TN_SCENE_WARNING` covers unlit foliage only | M |
+| L2 | `shadowProxy(root, { skip })`: merged shadow-only casters with the layer mask owned by the engine | `midway-open-pacific/src/render/world.ts`, about 170 lines; logged 2026-09-22 (351 submissions down to 5) | M |
+| L3 | `ProjectilePool` with swept sphere hits and a `SphereIndex` for near queries | `starlance` `Enemies.ts`, `Weapons.ts`, about 235 lines; `midway-open-pacific` allocates a `prev` object per projectile per tick | L |
+| L4 | `HudPainter`: instanced quads, bitmap font and `projectToHud` for games with no React | `starlance` `hud.ts`, `hudStyle.ts`, `cockpitHud.ts`, about 360 lines | L |
+| L5 | `heightFogNode` in `@threenative/core/render/environment` | 118 lines in 11 kits, 4 variants that differ by 1 or 2 lines | M |
+| L6 | Sky and cloud render-target plumbing reuses `Atmosphere` and `AtmosphereLuts` | About 370 lines in 6 kits; 0 kits construct `Atmosphere`. Open: whether the compute LUTs run on the owned engine | L |
+| L7 | `CharacterBody3D({ coyoteTime, jumpBuffer })` | 4 kits and `stress-lab`, about 20 lines each | S |
+| L8 | `runInSlices(generator, { budgetMs })` | `midway-open-pacific` passes fake 4 096-slot arrays to `addInSlices`, 52 lines | S |
+| L9 | `CueBank` over `AudioBus`: cooldown per cue, speed-of-sound delay, keyed loop set under a budget | `midway-open-pacific/src/audio.ts`, about 235 lines, 1 game | L |
+| L10 | Model textures take the engine anisotropy default | `midway-open-pacific` walks every texture 4 times; core sets `anisotropy = 8` only for terrain splats | M |
+| L11 | Shadow map size from a texel budget | `mobile ? 2048 : 4096` in 12 kit `lighting.ts` files (lead) | M |
+| L12 | Portable `GridNavigator` | A* hand-written in `rts` (276 lines) and `shooter`; `NavigationAgent3D` is browser-only | L |
+| L13 | `--template empty` kit, and `--force` into a folder that holds only `.packages/` | `fluid-lab` 2026-10-01; `minimal` holds 3 701 lines | M |
+
+Quick wins with no PRD (commit directly after the related check):
+
+- `TN_PLAYTEST_SCENARIO_ASSERTS_NOTHING` suggestion names `warmupFrames: 0`.
+- `TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING` suggestion names `doctor` and `--headed --browser-recipe webgpu`.
+- The `maxReadyMs: 8000` example in `packages/playtest/src/assertion-schema.ts` becomes a placeholder.
+- `atSteps` gains `gte`.
+
+## Not surveyed
+
+- **Engine package architecture.** The untangle batch (PRD-832 to PRD-842) and the god-files batch
+ (PRD-800 to PRD-804) closed between 2026-11-06 and 2026-11-08. This survey did not repeat them.
+- **Stale PRDs that overlap this batch.** PRD-203 (loading drift) and PRD-193 (allocation-free frames)
+ are NOT STARTED. PRD-874 Phase 1 and PRD-872 Phase 1 cover their goal by a different route. Closing
+ them is an owner call.
+- **Older sandbox games.** Only the three newest games were read in full.
diff --git a/packages/core/__tests__/state.spec.ts b/packages/core/__tests__/state.spec.ts
index 0c32bc824a..5fe179e464 100644
--- a/packages/core/__tests__/state.spec.ts
+++ b/packages/core/__tests__/state.spec.ts
@@ -98,3 +98,51 @@ describe("createGameStore", () => {
store.stop();
});
});
+
+describe("createGameStore changed-only publication", () => {
+ it("should publish nothing when every value put back is the one already published", () => {
+ const store = createGameStore({ credits: 40, score: 7 });
+ let notifications = 0;
+ const unsubscribe = store.subscribe(() => notifications++);
+
+ for (let frame = 0; frame < 600; frame++) {
+ store.set({ credits: 40, score: 7 });
+ store.flush();
+ }
+
+ expect(notifications).toBe(0);
+ unsubscribe();
+ store.stop();
+ });
+
+ it("should publish the one changed key and keep the unchanged siblings quiet", () => {
+ const store = createGameStore({ credits: 40, score: 7 });
+ const observed: Array<{ credits: number; score: number }> = [];
+ const unsubscribe = store.subscribe((state) => observed.push({ ...state }));
+
+ store.set({ credits: 40, score: 8 });
+ store.flush();
+
+ expect(observed).toEqual([{ credits: 40, score: 8 }]);
+ unsubscribe();
+ store.stop();
+ });
+
+ it("should publish nothing when the same object is mutated and set again", () => {
+ const inventory = { count: 1 };
+ const store = createGameStore<{ inventory: { count: number } }>({ inventory });
+ let notifications = 0;
+ const unsubscribe = store.subscribe(() => notifications++);
+
+ inventory.count = 2;
+ store.set({ inventory });
+ store.flush();
+ expect(notifications).toBe(0);
+
+ store.set({ inventory: { count: 2 } });
+ store.flush();
+ expect(notifications).toBe(1);
+ unsubscribe();
+ store.stop();
+ });
+});
diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json
index db3df42439..12c36ea4c7 100644
--- a/packages/core/capabilities.json
+++ b/packages/core/capabilities.json
@@ -1486,6 +1486,26 @@
"supersedes": [],
"symbol": "debugFlag"
},
+ {
+ "aliases": [],
+ "constraints": [
+ "the same name rule as `debugFlag`: `?quality=` on the web, `TN_DEBUG_QUALITY` on a native launch",
+ "a name in camelCase becomes UPPER_SNAKE: `debugValue(\"quality\")` reads `?quality=` or `TN_DEBUG_QUALITY`"
+ ],
+ "example": "import { debugValue } from \"@threenative/core\";\nconst tier = debugValue(\"quality\");",
+ "importPath": "@threenative/core",
+ "kind": "function",
+ "overrides": [],
+ "package": "@threenative/core",
+ "signature": "export function debugValue(name: string): string | undefined { … }",
+ "situations": [
+ "read a debug override whose value is not a boolean",
+ "override a quality tier or a mode for one run without changing the code"
+ ],
+ "summary": "Read the text a debug switch was given by the launch, on the same rule as `debugFlag`.",
+ "supersedes": [],
+ "symbol": "debugValue"
+ },
{
"aliases": [
"nearest target crosshair spawns",
@@ -10332,6 +10352,31 @@
"supersedes": [],
"symbol": "useUiStateDuringStartup"
},
+ {
+ "symbol": "ctx.assets.memo",
+ "package": "@threenative/core",
+ "importPath": "src/assets.ts",
+ "kind": "function",
+ "signature": "ctx.assets.memo(key: string, build: () => T | Promise): Promise",
+ "summary": "Returns what `build` returned the last time this build of the game ran, read back from disk, and calls `build` only when nothing is stored. Use it for seeded work that is the same every run: placement lists, painted sky pixels, terrain heights, a generated stage.",
+ "situations": [
+ "cache generated geometry between page loads",
+ "stop rebuilding the same generated stage on every page load",
+ "cache painted sky pixels or baked terrain heights between runs",
+ "keep seeded placement work the same every run without recomputing it",
+ "make a slow boot fast without shipping a baked asset"
+ ],
+ "aliases": [],
+ "example": "const { pixels } = await ctx.assets.memo(\"sky\", () => ({ pixels: paintSky() }));",
+ "constraints": [
+ "The value is named typed arrays; a map of names to typed arrays is what is stored.",
+ "On the dev server and on a native host `build` runs every time, and `TN_ASSET_MEMO` reports why: the cache is a build artifact, not a substitute for authoring.",
+ "A new build of the game regenerates: the key alone does not pin a value across a rebuild.",
+ "A game that drives a loader outside `defineGame` may call it whenever it likes; the reveal is not waited on."
+ ],
+ "overrides": ["TN_ASSET_MEMO reports why a run rebuilt instead of reading the store"],
+ "supersedes": []
+ },
{
"symbol": "createStormAudio",
"package": "template:rain",
@@ -10557,6 +10602,76 @@
"supersedes": [],
"aliases": []
},
+ {
+ "symbol": "ctx.after",
+ "package": "@threenative/core",
+ "importPath": "src/scene.ts",
+ "kind": "function",
+ "signature": "ctx.after(delay: number, callback: () => void): ScheduleHandle",
+ "summary": "Runs `callback` once, `delay` seconds of game time later, and returns a handle that cancels it. Game time stops while the game is paused, so a delayed beat does not fire behind a paused frame.",
+ "situations": [
+ "run a callback after a delay",
+ "schedule a one-shot timer that pauses with the game",
+ "do something a moment later without setTimeout",
+ "cancel a pending delayed action when a scene exits"
+ ],
+ "aliases": [],
+ "example": "const spawn = ctx.after(0.4, () => ctx.add(makeWave()));\nspawn(); // cancels",
+ "constraints": [
+ "The delay is game seconds: a paused game does not age it, and a slow frame does not skip it.",
+ "`setTimeout` keeps running while the game is paused and after the scene is gone; this does not.",
+ "Call the returned handle to cancel: a pending callback holds its scene's closure alive."
+ ],
+ "overrides": [],
+ "supersedes": []
+ },
+ {
+ "symbol": "ctx.beforeRender",
+ "package": "@threenative/core",
+ "importPath": "src/scene.ts",
+ "kind": "function",
+ "signature": "ctx.beforeRender(callback: () => void): () => void",
+ "summary": "Registers work that runs once per actual world render, after the frame's last fixed update and before the projection reconciles and the renderer draws. The last place to change a transform, a uniform or a camera before the frame is presented.",
+ "situations": [
+ "run a callback just before the frame renders",
+ "update a camera or a uniform after physics and before the draw",
+ "change a transform in the same frame it is seen",
+ "run per-render work that must not run on a loader-only frame"
+ ],
+ "aliases": [],
+ "example": "const stop = ctx.beforeRender(() => (material.uniforms.time.value = clock.elapsed));\n// later: stop();",
+ "constraints": [
+ "A held, loader-only frame has no world draw and dispatches nothing.",
+ "Runs after every fixed update in the frame, so a value it writes is the one drawn.",
+ "Returns an unregister function; call it when the object that needed it is gone."
+ ],
+ "overrides": [],
+ "supersedes": []
+ },
+ {
+ "symbol": "ctx.startup.progress",
+ "package": "@threenative/core",
+ "importPath": "src/scene.ts",
+ "kind": "function",
+ "signature": "ctx.startup.progress: number",
+ "summary": "How much of the start scene has loaded and presented, 0 to 1, for a loading cover's bar. It never falls: a loader whose pending work grows keeps the number it reached rather than jumping backwards.",
+ "situations": [
+ "boot progress bar",
+ "show how much of the start scene has loaded",
+ "loading screen percentage",
+ "a progress bar that never goes backwards",
+ "report startup progress while assets stream in"
+ ],
+ "aliases": [],
+ "example": "const width = `${Math.round(ctx.startup.progress * 100)}%`; // a cover the game draws on ctx.canvasLayer",
+ "constraints": [
+ "Never decreases, even when the loader is asked for more work mid-boot.",
+ "Reaches 1 only after the first world render has been presented, not when the last byte arrived.",
+ "A loading view the engine drew for you already reads it; this is for a cover the game draws itself."
+ ],
+ "overrides": [],
+ "supersedes": []
+ },
{
"symbol": "bloom",
"package": "three",
diff --git a/packages/core/src/debug.ts b/packages/core/src/debug.ts
index 089f2c2bef..4e14c0cf3b 100644
--- a/packages/core/src/debug.ts
+++ b/packages/core/src/debug.ts
@@ -139,6 +139,22 @@ export function debugFlag(name: string): boolean {
return readLaunchFlag({ environment: envName(name), query: name });
}
+/**
+ * The text this launch gave a debug switch, or `undefined` when no seam named it.
+ *
+ * `debugFlag`'s non-boolean sibling: the same naming rule — `?name` on the web, `TN_DEBUG_NAME` on
+ * a native launch — for a switch whose value is a word rather than a yes or no, such as a quality
+ * tier or a mode. An empty string means a seam named the switch and gave no value.
+ *
+ * @situation read a debug override whose value is not a boolean
+ * @situation override a quality tier or a mode for one run without changing the code
+ * @constraint a name in camelCase becomes UPPER_SNAKE: `debugValue("quality")` reads `?quality=` or `TN_DEBUG_QUALITY`
+ * @example const tier = debugValue("quality");
+ */
+export function debugValue(name: string): string | undefined {
+ return readLaunchValue({ environment: envName(name), query: name });
+}
+
/**
* Publish one game object for a capture script or the console, in development builds only.
*
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ac2c57f319..2793140691 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -456,6 +456,15 @@ export type { IMergeByMaterialOptions } from "./merge-parts.js";
* if (debugFlag("freeCam")) camera.flyMode = true;
*/
export { debugFlag } from "./debug.js";
+/**
+ * Read the text a debug switch was given by the launch, on the same rule as `debugFlag`.
+ * @situation read a debug override whose value is not a boolean
+ * @situation override a quality tier or a mode for one run without changing the code
+ * @constraint the same name rule as `debugFlag`: `?quality=` on the web, `TN_DEBUG_QUALITY` on a native launch
+ * @example import { debugValue } from "@threenative/core";
+ * const tier = debugValue("quality");
+ */
+export { debugValue } from "./debug.js";
/**
* Publish one game object under `__THREENATIVE__.debug` for a capture script or the console.
* @situation expose a game object to a capture script or the console in dev builds
diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts
index 57d539cfdc..7e07f0ee13 100644
--- a/packages/core/src/state.ts
+++ b/packages/core/src/state.ts
@@ -13,6 +13,11 @@ export type GameStore> = StoreApi & {
/**
* A store the UI reads, coalesced to one publication per frame unless a game names an interval.
*
+ * A key whose new value is `Object.is`-equal to the current value does not enter `pending`, so a
+ * frame that changed nothing publishes nothing and `flush` stays quiet. The flip side: mutating an
+ * object in place and setting it again is the same object, so nothing publishes — publish a new
+ * object or bump a version field on the value.
+ *
* `intervalMs` is an override, not the rule. A fixed 100 ms default published the HUD ten times a
* second whatever the frame rate was, which reads as a HUD that is not live — the number a player
* watches moves in visible steps while the scene beside it runs smoothly. Left unset, `flush` is
@@ -47,9 +52,17 @@ export function createGameStore>(
gameStore.getState = () => current;
gameStore.set = (patch: StatePatch) => {
const next = typeof patch === "function" ? patch(current) : patch;
- Object.assign(pending, next);
- Object.assign(current, next);
- hasPending = true;
+ let changed = false;
+ for (const key in next) {
+ const value = next[key];
+ // The value the HUD would publish is the one it already has: no publish, no cross-process
+ // write on native. Five kits hand-wrote this comparison before every `set`.
+ if (Object.is(value, current[key])) continue;
+ Reflect.set(pending, key, value);
+ Reflect.set(current, key, value);
+ changed = true;
+ }
+ if (changed) hasPending = true;
};
gameStore.flush = flush;
gameStore.start = () => {
diff --git a/packages/create-threenative/__tests__/authoring-quality.spec.ts b/packages/create-threenative/__tests__/authoring-quality.spec.ts
index bdcf596d9d..135bab58cd 100644
--- a/packages/create-threenative/__tests__/authoring-quality.spec.ts
+++ b/packages/create-threenative/__tests__/authoring-quality.spec.ts
@@ -141,7 +141,7 @@ describe("threenative/hot-callback-allocation", () => {
expect(rules(r)).toEqual(["threenative/hot-callback-allocation"]);
});
- it("stays quiet for startup allocation, deferred functions, non-scene classes and unrelated classes", async () => {
+ it("stays quiet for startup allocation, deferred functions and methods that are not frame names", async () => {
const quiet = await lint(
"s.ts",
SCENE(
@@ -151,9 +151,14 @@ describe("threenative/hot-callback-allocation", () => {
expect(quiet.messages).toEqual([]);
const other = await lint(
"o.ts",
- 'import { Vector3 } from "three";\nclass Other { update(): void { void new Vector3(); } }\nclass Vector2 {}\nexport class P { render(): void { void new Vector2(); } }\nexport { Other };\n',
+ 'import { Vector3 } from "three";\nclass Other { refresh(): void { void new Vector3(); } }\nclass Vector2 {}\nexport class P { render(): void { void new Vector2(); } }\nexport { Other };\n',
);
expect(other.messages).toEqual([]);
+ const entity = await lint(
+ "e.ts",
+ 'import { Vector3 } from "three";\nexport class Enemies { update(): void { void new Vector3(); } }\n',
+ );
+ expect(rules(entity)).toEqual(["threenative/hot-callback-allocation"]);
});
it("leaves authored bytes unchanged", async () => {
diff --git a/packages/create-threenative/__tests__/quality-rules.spec.ts b/packages/create-threenative/__tests__/quality-rules.spec.ts
index 396afe3f0a..eff74a0601 100644
--- a/packages/create-threenative/__tests__/quality-rules.spec.ts
+++ b/packages/create-threenative/__tests__/quality-rules.spec.ts
@@ -1,7 +1,13 @@
+import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { InputMap } from "@threenative/core";
import tsParser from "@typescript-eslint/parser";
import { Linter } from "eslint";
+import { MathUtils } from "three";
import { describe, expect, it } from "vitest";
import { threenativePlugin } from "../src/quality/plugin.js";
+import { REINVENTIONS, reinventionConfigs, reinventionRules } from "../src/quality/reinventions.js";
const RULES = [
"hot-callback-allocation",
@@ -167,6 +173,30 @@ describe("threenative/startup-override-reason", () => {
});
});
+describe("threenative/hot-callback-* in an entity update method", () => {
+ const entity = (body: string): string => `import * as THREE from "three";
+export class Enemies {\n${body}\n}\n`;
+
+ it("treats a non-static update, fixedUpdate, tick or step as per-frame", () => {
+ for (const name of ["update", "fixedUpdate", "tick", "step"]) {
+ expect(fired(entity(`${name}() { const v = new THREE.Vector2(); return v; }`))).toEqual([
+ "threenative/hot-callback-allocation",
+ ]);
+ }
+ expect(fired(entity("update() { this.world.traverse(() => undefined); }"))).toEqual([
+ "threenative/hot-callback-scene-work",
+ ]);
+ });
+
+ it("passes construction in load, a static update, and a method that is not a frame name", () => {
+ expect(fired(entity("load() { const v = new THREE.Vector2(); return v; }"))).toEqual([]);
+ expect(fired(entity("static update() { const v = new THREE.Vector2(); return v; }"))).toEqual(
+ [],
+ );
+ expect(fired(entity("refresh() { const v = new THREE.Vector2(); return v; }"))).toEqual([]);
+ });
+});
+
describe("threenative/hot-callback-allocation after the shared tracker", () => {
it("still flags a Vector3 in update and passes it in enter", () => {
expect(fired(scene("update() { const v = new THREE.Vector3(); return v; }"))).toEqual([
@@ -175,3 +205,167 @@ describe("threenative/hot-callback-allocation after the shared tracker", () => {
expect(fired(scene("enter() { this.v = new THREE.Vector3(); }"))).toEqual([]);
});
});
+
+describe("the reinvention rows", () => {
+ /** One hand-written form and the call that replaces it, per row. */
+ const CASES: readonly {
+ readonly capability: string;
+ readonly bad: string;
+ readonly good: string;
+ }[] = [
+ {
+ capability: "createRandom",
+ bad: "const r = Math.random();\n",
+ good: "const random = createRandom(7)();\n",
+ },
+ {
+ capability: "createRandom",
+ bad: "let s = 7;\ns = (Math.imul(s, 1664525) + 1013904223) | 0;\n",
+ good: "const random = createRandom(7)();\n",
+ },
+ {
+ capability: "createRandom",
+ bad: "let a = 7;\na = (a + 0x6d2b79f5) | 0;\n",
+ good: "const random = createRandom(7)();\n",
+ },
+ {
+ capability: "MathUtils.damp",
+ bad: "const k = 1 - Math.exp(-dt * 4);\n",
+ good: "const k = MathUtils.damp(a, b, 4, dt);\n",
+ },
+ {
+ capability: "debugFlag",
+ bad: 'const asked = new URLSearchParams(location.search).get("quality");\n',
+ good: 'const asked = debugValue("quality");\n',
+ },
+ {
+ capability: "renderer.matrixWorld",
+ bad: "scene.matrixWorldAutoUpdate = false;\n",
+ good: "const config = { renderer: { matrixWorld: true } };\n",
+ },
+ {
+ capability: "InputMap.axis",
+ bad: 'const x = (keys.has("KeyA") ? 1 : 0) - (keys.has("KeyD") ? 1 : 0);\n',
+ good: 'const x = ctx.input.axis("moveX");\n',
+ },
+ {
+ capability: "MathUtils.smoothstep",
+ bad: "const t = u * u * (3 - 2 * u);\n",
+ good: "const t = MathUtils.smoothstep(u, 0, 1);\n",
+ },
+ ];
+
+ /** Lint one file with only `rows` on, at error. */
+ function reinvented(
+ rows: Parameters[0],
+ source: string,
+ filename = "src/game.ts",
+ ): string[] {
+ return new Linter()
+ .verify(
+ source,
+ [
+ {
+ files: ["src/**/*.ts"],
+ languageOptions: { parser: tsParser as Linter.Parser },
+ rules: reinventionRules(rows),
+ },
+ ],
+ filename,
+ )
+ .map((message) => message.ruleId ?? `parse:${message.message}`);
+ }
+
+ const row = (capability: string) => {
+ const found = REINVENTIONS.find((candidate) => candidate.capability === capability);
+ if (found === undefined) throw new Error(`no reinvention row names ${capability}`);
+ return found;
+ };
+
+ it("fails each hand-written form and passes the call it names", () => {
+ for (const test of CASES) {
+ expect(reinvented([row(test.capability)], test.bad), test.bad).not.toEqual([]);
+ expect(reinvented([row(test.capability)], test.good), test.good).toEqual([]);
+ }
+ });
+
+ it("names only a symbol that still exists", () => {
+ const manifest = JSON.parse(
+ readFileSync(new URL("../../core/capabilities.json", import.meta.url), "utf8"),
+ ) as { entries: readonly Record[] };
+ const entries = new Map(
+ manifest.entries.map((entry) => [String(entry.symbol), JSON.stringify(entry)]),
+ );
+ for (const reinvention of REINVENTIONS) {
+ const [owner, member] = reinvention.capability.split(".");
+ if (reinvention.source === "three") {
+ expect(owner, reinvention.capability).toBe("MathUtils");
+ const members = MathUtils as unknown as Record;
+ expect(typeof members[member ?? ""], reinvention.capability).toBe("function");
+ continue;
+ }
+ if (entries.has(reinvention.capability)) continue;
+ const ownerRow = owner === undefined ? undefined : entries.get(owner);
+ expect(ownerRow, reinvention.capability).toBeDefined();
+ expect(ownerRow?.includes(member ?? ""), reinvention.capability).toBe(true);
+ }
+ // The messages also name these, and a message that names nothing is the bug the spec exists for.
+ expect(entries.has("debugValue"), "debugValue").toBe(true);
+ expect(typeof (InputMap.prototype as unknown as Record).axis).toBe("function");
+ });
+
+ it("should pass every kit's own source under the hot rules and the rows", () => {
+ const templatesRoot = fileURLToPath(new URL("../templates", import.meta.url));
+ const sourceFiles = (directory: string): string[] =>
+ readdirSync(directory).flatMap((entry) => {
+ const file = path.join(directory, entry);
+ if (statSync(file).isDirectory()) return sourceFiles(file);
+ return /\.tsx?$/u.test(entry) ? [file] : [];
+ });
+ const files = readdirSync(templatesRoot).flatMap((kit) => {
+ const source = path.join(templatesRoot, kit, "src");
+ if (!existsSync(source)) return [];
+ return sourceFiles(source).map((file) => ({ file, kit }));
+ });
+ // Fail closed: a discovery bug must not report a green zero.
+ expect(files.length).toBeGreaterThan(300);
+
+ const found: string[] = [];
+ for (const { file, kit } of files) {
+ const relative = path.relative(path.join(templatesRoot, kit), file);
+ const messages = new Linter().verify(
+ readFileSync(file, "utf8"),
+ [
+ {
+ files: ["src/**/*.ts", "src/**/*.tsx"],
+ languageOptions: { parser: tsParser as Linter.Parser },
+ plugins: { threenative: threenativePlugin },
+ rules: {
+ "threenative/hot-callback-allocation": "error",
+ "threenative/hot-callback-resource-creation": "error",
+ "threenative/hot-callback-scene-work": "error",
+ },
+ },
+ ...reinventionConfigs(["src/**/*.ts", "src/**/*.tsx"]),
+ ],
+ relative,
+ );
+ for (const message of messages)
+ found.push(`${kit}/${relative}:${message.line} ${message.message}`);
+ }
+ expect(found).toEqual([]);
+ });
+
+ it("keeps a path's excluded rows out and leaves the others on", () => {
+ const configs = reinventionConfigs(["src/**/*.ts"]);
+ const lint = (source: string, filename: string): string[] =>
+ new Linter()
+ .verify(source, configs, filename)
+ .map((message) => message.ruleId ?? `parse:${message.message}`);
+ const ui = "const r = Math.random();\nconst t = u * u * (3 - 2 * u);\n";
+ // The UI keeps the platform RNG and still gets the smoothstep row.
+ expect(lint(ui, "src/ui/hud.ts").length).toBe(1);
+ // The same two lines in a scene are both reinventions.
+ expect(lint(ui, "src/scenes/Play.ts").length).toBe(2);
+ });
+});
diff --git a/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx
index 8c19355a92..edf5051a42 100644
--- a/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx
+++ b/packages/create-threenative/__tests__/rain-quality-initialization.spec.tsx
@@ -55,13 +55,17 @@ describe("Rain initial quality ownership", () => {
qualityExplicit: false,
loading: { ready: false, progress: 0 },
};
+ // A browser's `window.location` is the same object as `globalThis.location`, and the engine's
+ // debug seam reads the latter; the stub shares one object so `?quality=` reaches both.
+ const location = { search: "" };
vi.stubGlobal("window", {
innerWidth: 1440,
- location: { search: "" },
+ location,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
matchMedia: () => ({ matches: hooks.media }),
});
+ vi.stubGlobal("location", location);
vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() });
hooks.media = false;
});
diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts
index f935cfa20c..f70b4a3bb7 100644
--- a/packages/create-threenative/__tests__/scaffold.spec.ts
+++ b/packages/create-threenative/__tests__/scaffold.spec.ts
@@ -117,67 +117,67 @@ const SCAFFOLD_TREE_HASHES: Readonly> = {
// scaffold-pin:begin
"action-rpg": {
shared: "89aa333d822a6627d2ad23f96521abf4ea7ba92bcec1ed55f9c30a8ee83ff250",
- kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa",
+ kit: "3abc2cea04a93caa14ff1ad36a7f55c310f310f0c56462ed4e25c4ee1a1fd48c",
},
adventure: {
shared: "ddac540bdf78253f87db7432d1b80a2a4dc7e20809cd1006b3cc261492b7d6fa",
- kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a",
+ kit: "0c54f1d66dc0aa5ab2720f49c27ae824b9f672a89bdaa2a888e6de349e90b8b6",
},
collectathon: {
shared: "3a0df8488b8d94deb01464e10fabde2d6ac4e65647e98f39f5afb235ec71ffae",
- kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72",
+ kit: "69fcac12372cecbac729d2fa9b5b30aa00c95497209299444cea4da3cec15859",
},
kart: {
shared: "676434e30b0ed4e2dfe698e5d6ab393e47fe7779ef7df617ca8f12f1f24c7748",
- kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2",
+ kit: "35f8fba0f18121466cb5750a456ffd9fe94692391ecd25219a8f4816c28c661a",
},
minimal: {
shared: "f4b43e245b3a9810947d7a3b5652b409ffb097cafb7834ed215567f83d3379af",
- kit: "29c32badb8a5c214d2284d6a2635570c03f3241feb8180cce5da681cd8222b96",
+ kit: "cc82ebcaec522ba5786ee160857c8664048adb49208e5d89ea197bceebc2ef41",
},
platformer: {
shared: "0720f0c8f3374f5d41e7540dd3fe16f4ceeb2b5717fe2b163bafd4d7742983a8",
- kit: "acb3a17cf7c1371188eedeab5e7a921929c56e35461f9c18c92edd031a56393f",
+ kit: "828eddf479a880adcf2f81fdb493a32ea1724a738daa95fd19e628cf6fe22e1a",
},
puzzle: {
shared: "865623ec3097b295dfc67012b3e61eaba0d7a322e2b6ec59620478863fdbdae6",
- kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401",
+ kit: "f3c2ecbaf4ab1294827379efc6af8e05595e67aff5b4e2d08642b2923c750eaf",
},
racing: {
shared: "8463fb44077a88e0c5938db571af259c59f668eedca9d477a0bc4b70239c3c60",
- kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9",
+ kit: "d2552be8ba971387ea086ea8ed39beca7ca7492a7bc175ba9b0db9eb533d39c7",
},
rain: {
shared: "f3d68ab1eb2759202e15499046cc80d362d3586be314f68610eed0bf2c20367e",
- kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96",
+ kit: "62155c8e3aaad74cc49bc0c2063a61a7bf1bc78ad28d40fee4708e1f1dfefd5a",
},
rts: {
shared: "9e7c20d1b7eceb245eb9b1db1e30d1294f6533a9b69ef0f3e3bfad1c67d86b38",
- kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669",
+ kit: "11c32528f07476b2a5ac2a214529e611c22a4317a8c89aa96b9c5648208f84b8",
},
runner: {
shared: "cd43eda17fbdc91bedb7680441daf0a30dee0f3b1e19bacebb124ab4471ff1ba",
- kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158",
+ kit: "2c3f0a910f0ad2b4895795926c4a1fb5cb4336eb615ecc3f6347fa82170f5da9",
},
sailing: {
shared: "6e39c4c67676a2c1d4ae7fdf148a0e7cc4dde3b6207b49da84701a4266e720c0",
- kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0",
+ kit: "fbfd8c80d5e7c0ec8f42ed9697ce9df9e4649f19aa28e51e1f20550f45b6fd2e",
},
shooter: {
shared: "dbe1eb0f2872275fc9759deea95c4c624f7fa82fb7adccab3d22a20889cabaac",
- kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5",
+ kit: "8b8fadb8b10756d56af91dd08c1e6c16a9fd9409dc24b2f989c60cec22c67d4f",
},
snow: {
shared: "130389a38fb48663bd0cd391751ab3d2eb0c47e0d535e236b09366fd1fc93099",
- kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823",
+ kit: "7856c03a93624b00854cdcae6a08d8921e7ff6b4735a6beafd94b750dd89fa70",
},
starter: {
shared: "000bd75177222e5b3ab03beee4118b7c63a8dd59fdc316487d61ff7b3758376d",
- kit: "9b70f462c27add3f689ea04925631afe92ab296b36309660107b900bf7f40a69",
+ kit: "35466c0bf26142c37b789a5b434473279b40018b5a8dc58145d5717fa75c6608",
},
"tower-defense": {
shared: "119e88ddb3fdcf2f3f4c0aade6e62e4b480e2b6a1292d69552040fb6e8083470",
- kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0",
+ kit: "a99e95e97957bf1e940821629f926b7ddb180d438d6b5795f701885d313fc5df",
},
// scaffold-pin:end
};
diff --git a/packages/create-threenative/agent-docs/references/performance-basics.md b/packages/create-threenative/agent-docs/references/performance-basics.md
index c950c294cc..0ee4d4e3be 100644
--- a/packages/create-threenative/agent-docs/references/performance-basics.md
+++ b/packages/create-threenative/agent-docs/references/performance-basics.md
@@ -134,7 +134,8 @@ change a line.
`pnpm check` runs the strict typecheck, ESLint and a Biome format check, and `pnpm test` runs it
first. ESLint fails on a floating Promise and on an async callback given to a synchronous slot, and
it warns when a `new Vector3()`-style math constructor runs directly inside `Scene.update`,
-`Scene.render` or a `ctx.beforeRender` callback. That warning is a prompt to measure, not a
+`Scene.render`, an entity's `update`/`fixedUpdate`/`tick`/`step` method or a `ctx.beforeRender`
+callback. That warning is a prompt to measure, not a
finding: reuse an instance the scene owns only if `TN_FRAME_BUDGET` shows allocation pressure.
It does not follow helper calls, so a quiet result is not proof a frame allocates nothing. A
suppression needs a reason: `// eslint-disable-next-line -- `. Lint does not prove a
diff --git a/packages/create-threenative/src/quality/hotCallbackAllocation.ts b/packages/create-threenative/src/quality/hotCallbackAllocation.ts
index 685b37809b..6166d45996 100644
--- a/packages/create-threenative/src/quality/hotCallbackAllocation.ts
+++ b/packages/create-threenative/src/quality/hotCallbackAllocation.ts
@@ -13,7 +13,8 @@ function importedName(
}
/** Warns on a Three.js math constructor that runs directly inside a per-frame callback: a
- * `Scene` `update`/`render` method or a function registered with `ctx.beforeRender` or `ctx.afterPhysics`. It is
+ * `Scene` `update`/`render` method, a non-static `update`/`fixedUpdate`/`tick`/`step` method on an
+ * entity class, or a function registered with `ctx.beforeRender` or `ctx.afterPhysics`. It is
* syntactic on purpose. A callback hidden behind a helper is outside its coverage. */
export const hotCallbackAllocation: Rule.RuleModule = {
meta: {
diff --git a/packages/create-threenative/src/quality/hotCallbacks.ts b/packages/create-threenative/src/quality/hotCallbacks.ts
index 75cc6b26d9..33a2d94d1e 100644
--- a/packages/create-threenative/src/quality/hotCallbacks.ts
+++ b/packages/create-threenative/src/quality/hotCallbacks.ts
@@ -2,6 +2,10 @@ import type { Rule } from "eslint";
import type * as ESTree from "estree";
const FRAME_METHODS = new Set(["render", "update"]);
+/** Method names a class other than a `Scene` may use for its own per-frame step. A game's entity
+ * class is not a node, so the engine cannot see its `update`, and 22 allocation or tree-walk sites
+ * in kit entity `update` methods were invisible to the rules. */
+const ENTITY_FRAME_METHODS = new Set(["fixedUpdate", "step", "tick", "update"]);
/** Registration calls whose first argument runs every frame (or every render call), by label. The
* TSL node hooks run once per render call, and a post chain makes many a frame: the shooter's
* backlight validated its controls in eight `onRenderUpdate` callbacks, 525 bridge reads a frame. */
@@ -58,8 +62,24 @@ function hookName(parent: ESTree.Node, node: FunctionNode): string | undefined {
return name === undefined ? undefined : FRAME_HOOKS.get(name);
}
+/** The label a class method carries when it runs each frame: `Scene.update`, or `Enemies.tick` for a
+ * game's own entity class. */
+function methodLabel(parent: Rule.Node, sceneLocals: ReadonlySet): string | undefined {
+ if (parent.type !== "MethodDefinition" || parent.static || parent.key.type !== "Identifier")
+ return undefined;
+ const owner = (parent.parent?.parent as ESTree.Class | undefined) ?? undefined;
+ const base = owner?.superClass;
+ const isSceneBase = base?.type === "Identifier" && sceneLocals.has(base.name);
+ if (isSceneBase)
+ return FRAME_METHODS.has(parent.key.name) ? `Scene.${parent.key.name}` : undefined;
+ if (!ENTITY_FRAME_METHODS.has(parent.key.name)) return undefined;
+ const className = owner?.id?.name;
+ return className === undefined ? parent.key.name : `${className}.${parent.key.name}`;
+}
+
/** Tracks whether the visitor is directly inside a per-frame callback: a `Scene` `update`/`render`
- * method, a function registered with `ctx.beforeRender` / `ctx.afterPhysics`, a TSL node's
+ * method, a non-static `update`/`fixedUpdate`/`tick`/`step` method on any other class, a function
+ * registered with `ctx.beforeRender` / `ctx.afterPhysics`, a TSL node's
* `onRenderUpdate` / `onFrameUpdate` / `onObjectUpdate`, or `setAnimationLoop`; a callback handed to
* an array iterator inside one is inside it too. Syntactic on purpose: a callback hidden behind a
* helper is outside its coverage. Shared by every rule that means "this must not run every frame". */
@@ -69,11 +89,8 @@ export function trackHotCallbacks(): IHotCallbacks {
const frameName = (node: FunctionNode): string | undefined => {
const parent = (node as Rule.Node).parent as Rule.Node;
- if (parent.type === "MethodDefinition" && !parent.static && parent.key.type === "Identifier") {
- const base = (parent.parent?.parent as ESTree.Class | undefined)?.superClass;
- const isSceneBase = base?.type === "Identifier" && sceneLocals.has(base.name);
- if (isSceneBase && FRAME_METHODS.has(parent.key.name)) return `Scene.${parent.key.name}`;
- }
+ const method = methodLabel(parent, sceneLocals);
+ if (method !== undefined) return method;
const hook = hookName(parent, node);
if (hook !== undefined) return hook;
const member = calleeMember(parent, node);
diff --git a/packages/create-threenative/src/quality/index.ts b/packages/create-threenative/src/quality/index.ts
index 16da6bc0eb..12651f89a5 100644
--- a/packages/create-threenative/src/quality/index.ts
+++ b/packages/create-threenative/src/quality/index.ts
@@ -6,6 +6,7 @@ import tsParser from "@typescript-eslint/parser";
import type { Linter } from "eslint";
import reactHooks from "eslint-plugin-react-hooks";
import { threenativePlugin } from "./plugin.js";
+import { reinventionConfigs } from "./reinventions.js";
export interface IQualityOptions {
/** The project root: where `tsconfig.json` lives and the typed rules resolve from. */
@@ -65,6 +66,7 @@ export function qualityConfig({ root }: IQualityOptions): Linter.Config[] {
},
},
];
+ config.push(...reinventionConfigs(SOURCE));
if (hasReact(root)) {
config.push({
files: ["src/**/*.tsx"],
diff --git a/packages/create-threenative/src/quality/reinventions.ts b/packages/create-threenative/src/quality/reinventions.ts
new file mode 100644
index 0000000000..146644a397
--- /dev/null
+++ b/packages/create-threenative/src/quality/reinventions.ts
@@ -0,0 +1,110 @@
+import type { Linter } from "eslint";
+
+/**
+ * The hand-written forms of a capability the engine already ships, one row each.
+ *
+ * Every row is a `no-restricted-syntax` selector, so the rule that rejects them is ESLint's own
+ * and this file is data. The three newest games rebuilt `MatrixWorldPass`, `InputMap` axes and
+ * `createRandom` by hand because the author never saw them, about 350 lines of shipped capability
+ * written again; the lint runs in every generated game, so it is the cheapest place to say the
+ * name. `__tests__/quality-rules.spec.ts` proves each row fails a fixture, passes the fixed form,
+ * and that its `capability` still resolves in the manifest (or in `three`) — a message cannot name
+ * a symbol that was removed.
+ */
+export interface IReinvention {
+ /** What the error says, naming the engine call and where it comes from. */
+ readonly message: string;
+ /** The symbol the message names; the spec proves it still exists. */
+ readonly capability: string;
+ /** Where the named symbol lives: a core export, or a `MathUtils` member from `three`. */
+ readonly source: "core" | "three";
+ /** The selector where the named call replaces the hand-written form. */
+ readonly selector: string;
+ /** Project paths where the hand-written form is the content, not a reinvention. */
+ readonly outside?: readonly string[];
+}
+
+/**
+ * Where a hand-written PRNG is the content, not a reinvention of the engine's seeded stream.
+ *
+ * `src/render/` stays plain Three.js by rule: a kit's render sources may not import a core
+ * symbol that is not in `RENDER_PORTABLE_CORE_SYMBOLS` (`scripts/check-core-boundary.ts`), and
+ * their noise and placement streams are authored against those exact numbers — a different
+ * generator draws different clouds. Audio synthesis and UI animation likewise keep the platform
+ * RNG. A level, an entity or a scene uses `createRandom`.
+ */
+export const PRNG_OUTSIDE = ["src/audio.ts", "src/audio/**", "src/render/**", "src/ui/**"] as const;
+
+export const REINVENTIONS: readonly IReinvention[] = [
+ {
+ selector:
+ ':matches(CallExpression[callee.object.name="Math"][callee.property.name="random"], Literal[value=1664525], Literal[value=1013904223], Literal[value=1831565813])',
+ capability: "createRandom",
+ source: "core",
+ outside: PRNG_OUTSIDE,
+ message:
+ "A hand-written PRNG (Math.random or the LCG constants) is a capability the engine ships: use `createRandom(seed)` from `@threenative/core`, or `ctx.random`, so the same seed replays the same game.",
+ },
+ {
+ selector:
+ 'BinaryExpression[operator="-"][left.value=1][right.callee.object.name="Math"][right.callee.property.name="exp"]',
+ capability: "MathUtils.damp",
+ source: "three",
+ message:
+ "`1 - Math.exp(-…)` is a damp factor the engine ships: use `MathUtils.damp(current, target, rate, dt)` from `three`, which is the same curve with the rate named.",
+ },
+ {
+ selector:
+ ':matches(MemberExpression[object.name="location"][property.name="search"], MemberExpression[object.property.name="location"][property.name="search"])',
+ capability: "debugFlag",
+ source: "core",
+ message:
+ "Reading `location.search` by hand is a launch seam the engine owns: use `debugFlag(name)` or `debugValue(name)` from `@threenative/core` (`?name` on web, `TN_DEBUG_NAME` on native).",
+ },
+ {
+ selector: 'AssignmentExpression[left.property.name="matrixWorldAutoUpdate"][right.value=false]',
+ capability: "renderer.matrixWorld",
+ source: "core",
+ message:
+ "Turning `matrixWorldAutoUpdate` off by hand is the engine's own walk: set `renderer.matrixWorld` in the game config for a whole scene, or `markStatic(node)` for one node that never moves.",
+ },
+ {
+ selector:
+ 'BinaryExpression[operator="-"][left.type="ConditionalExpression"][left.consequent.value=1][left.alternate.value=0][right.type="ConditionalExpression"][right.consequent.value=1][right.alternate.value=0]',
+ capability: "InputMap.axis",
+ source: "core",
+ message:
+ "An axis built from raw keys is a binding the engine already reads: declare it in `defineGame({ input })` and use `ctx.input.axis(name)` from `@threenative/core`.",
+ },
+ {
+ selector:
+ 'BinaryExpression[operator="*"][right.type="BinaryExpression"][right.operator="-"][right.left.value=3][right.right.type="BinaryExpression"][right.right.operator="*"][right.right.left.value=2]',
+ capability: "MathUtils.smoothstep",
+ source: "three",
+ message:
+ "`x * x * (3 - 2 * x)` is the engine's smoothstep: use `MathUtils.smoothstep(x, 0, 1)` from `three`, which clamps the same way.",
+ },
+];
+
+/** The `no-restricted-syntax` entries for one group of rows. */
+export function reinventionRules(rows: readonly IReinvention[]): Linter.RulesRecord {
+ return {
+ "no-restricted-syntax": [
+ "error",
+ ...rows.map(({ selector, message }) => ({ selector, message })),
+ ],
+ };
+}
+
+/**
+ * The two config objects the rows need: every row everywhere, and the rows a project path excludes
+ * on top. Later objects win a rule for a file both match, so the wider list comes last.
+ */
+export function reinventionConfigs(files: readonly string[]): Linter.Config[] {
+ const always = REINVENTIONS.filter((row) => row.outside === undefined);
+ const ignores = [...new Set(REINVENTIONS.flatMap((row) => row.outside ?? []))];
+ return [
+ { files: [...files], rules: reinventionRules(always) },
+ { files: [...files], ignores, rules: reinventionRules(REINVENTIONS) },
+ ];
+}
diff --git a/packages/create-threenative/templates/action-rpg/src/loot/drops.ts b/packages/create-threenative/templates/action-rpg/src/loot/drops.ts
index 4d52b6ed41..c688c49337 100644
--- a/packages/create-threenative/templates/action-rpg/src/loot/drops.ts
+++ b/packages/create-threenative/templates/action-rpg/src/loot/drops.ts
@@ -1,3 +1,4 @@
+import { createRandom } from "@threenative/core";
import type { ItemId } from "../items/Inventory.js";
export interface IDrop {
@@ -10,19 +11,14 @@ const TABLE: readonly { readonly itemId: ItemId; readonly weight: number }[] = [
{ itemId: "ember-blade", weight: 30 },
];
-function next(seed: number): number {
- return (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
-}
-
export function rollDrops(seed: number, rolls = 2): IDrop[] {
if (!Number.isInteger(seed) || seed < 0)
throw new RangeError("Drop seeds must be non-negative integers.");
if (!Number.isInteger(rolls) || rolls < 1) throw new RangeError("Drop rolls must be positive.");
- let state = seed >>> 0;
+ const random = createRandom(seed);
const drops: IDrop[] = [];
for (let index = 0; index < rolls; index += 1) {
- state = next(state);
- const pick = (state / 4_294_967_296) * 100;
+ const pick = random() * 100;
let cursor = 0;
for (const entry of TABLE) {
cursor += entry.weight;
diff --git a/packages/create-threenative/templates/action-rpg/src/render/camera.ts b/packages/create-threenative/templates/action-rpg/src/render/camera.ts
index ea64641ed8..1bf9787d3e 100644
--- a/packages/create-threenative/templates/action-rpg/src/render/camera.ts
+++ b/packages/create-threenative/templates/action-rpg/src/render/camera.ts
@@ -3,7 +3,7 @@
// The action-RPG three-quarter view: high and behind, far enough back that the room reads as a
// room. It has to clear the **south wall**, which stands at z = +6 and is low so the camera can
// see over it — the oldest trick in an isometric dungeon, and it costs one row of wall table.
-import { type PerspectiveCamera, Vector3 } from "three";
+import { MathUtils, type PerspectiveCamera, Vector3 } from "three";
/** Where the camera sits relative to the player's body centre. */
const OFFSET = new Vector3(0, 10.2, 9.2);
@@ -25,7 +25,7 @@ export function createDungeonCamera(camera: PerspectiveCamera): ICameraRig {
return {
follow: (target, dt) => {
_desired.copy(target).add(OFFSET);
- camera.position.lerp(_desired, 1 - Math.exp(-dt / 0.2));
+ camera.position.lerp(_desired, MathUtils.damp(0, 1, 5, dt));
camera.lookAt(_aim.copy(target).add(AIM));
},
snap: pose,
diff --git a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/action-rpg/src/render/clouds.ts
+++ b/packages/create-threenative/templates/action-rpg/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/action-rpg/src/render/materials.ts b/packages/create-threenative/templates/action-rpg/src/render/materials.ts
index 6b139b31d3..181db224d5 100644
--- a/packages/create-threenative/templates/action-rpg/src/render/materials.ts
+++ b/packages/create-threenative/templates/action-rpg/src/render/materials.ts
@@ -11,6 +11,7 @@ import {
DataTexture,
Float32BufferAttribute,
LinearMipmapLinearFilter,
+ MathUtils,
MeshStandardMaterial,
RepeatWrapping,
SRGBColorSpace,
@@ -84,8 +85,8 @@ function grainNormalTexture(size = 128): DataTexture {
const y0 = Math.floor(v);
const fx = u - x0;
const fy = v - y0;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
+ const sx = MathUtils.smoothstep(fx, 0, 1);
+ const sy = MathUtils.smoothstep(fy, 0, 1);
const at = (i: number, j: number): number =>
values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
diff --git a/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts b/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts
index a7a4612e71..7ace35728d 100644
--- a/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/action-rpg/src/render/touch-controls.ts
@@ -15,6 +15,9 @@ const MOVE_RADIUS = 72;
const BUTTON_RADIUS = 58;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -122,13 +125,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#ability.material = ability ? this.#activeMaterial : this.#idleMaterial;
this.#attack.material = attack ? this.#activeMaterial : this.#idleMaterial;
this.#input.abilityPressed = ability && !this.#wasAbility;
diff --git a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts
index 3fb77e7c11..9d30315906 100644
--- a/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts
+++ b/packages/create-threenative/templates/action-rpg/src/scenes/Play.ts
@@ -580,7 +580,6 @@ export class Play extends Scene {
}
currentRoom = roomFor(player.mesh.position.x);
- const previous = frameCtx.state.getState();
frameState.abilityCooldown = quantize(ability.cooldownRemaining, 1000);
frameState.baseDamage = damageStats.base;
frameState.damage = Math.round(damageStats.value(elapsed));
@@ -595,20 +594,7 @@ export class Play extends Scene {
visibleEnemy.state === "aggro" || visibleEnemy.state === "attack" ? 1 : 0;
frameState.wallEnemyAggro =
wallEnemy.state === "aggro" || wallEnemy.state === "attack" ? 1 : 0;
- const changed =
- frameState.abilityCooldown !== previous.abilityCooldown ||
- frameState.baseDamage !== previous.baseDamage ||
- frameState.damage !== previous.damage ||
- frameState.health !== previous.health ||
- frameState.lineOfSightBlocked !== previous.lineOfSightBlocked ||
- frameState.modifierActive !== previous.modifierActive ||
- frameState.playerX !== previous.playerX ||
- frameState.playerY !== previous.playerY ||
- frameState.playerZ !== previous.playerZ ||
- frameState.room !== previous.room ||
- frameState.visibleEnemyAggro !== previous.visibleEnemyAggro ||
- frameState.wallEnemyAggro !== previous.wallEnemyAggro;
- if (changed) frameCtx.state.set(frameState);
+ frameCtx.state.set(frameState);
if (currentRoom !== lastSavedRoom) {
lastSavedRoom = currentRoom;
saveRequested = true;
diff --git a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts
index 25c6fcf061..9364319be7 100644
--- a/packages/create-threenative/templates/adventure/src/entities/LockOn.ts
+++ b/packages/create-threenative/templates/adventure/src/entities/LockOn.ts
@@ -1,5 +1,5 @@
import type { Group, PerspectiveCamera, Vector3 } from "three";
-import { Box3, MathUtils, Vector3 as V3 } from "three";
+import { Box3, MathUtils, Mesh, Vector3 as V3 } from "three";
import { createReticle } from "../render/creatures.js";
/** Anything Wren can lock onto and cut: Mosslings, the Warden, the practice dummies. */
@@ -60,6 +60,12 @@ export class LockOn {
readonly #view = new V3();
readonly #to = new V3();
readonly #marker = new Box3();
+ /** The reticle's own box, measured once: it only moves, turns and scales. */
+ readonly #reticleBox = new Box3().setFromObject(this.reticle);
+ readonly #partBox = new Box3();
+ /** The target's meshes, collected once per target. */
+ #meshSource: ITarget | undefined;
+ #meshes: Mesh[] = [];
update(
dt: number,
@@ -94,14 +100,32 @@ export class LockOn {
this.#sticky = this.target !== undefined;
this.justAcquired = this.target !== undefined;
}
+ }
+
+ /**
+ * Bounds and reticle for this frame, after everything that moves has moved.
+ *
+ * Called separately from `update` because the lock must be picked before the hero reads its
+ * target, while the creatures it frames move later in the frame; placing it here is what keeps
+ * the arrow on the body rather than a frame behind it.
+ */
+ place(camera: PerspectiveCamera, dt: number): void {
const target = this.target;
this.reticle.visible = target !== undefined;
this.markerLift = 0;
this.markerTop = 0;
if (target !== undefined) {
// The target's live bounds, not a fixed offset: a rearing Warden or a hopping Mossling keeps
- // the arrow over its real top.
- const box = this.bounds.setFromObject(target.mesh);
+ // the arrow over its real top. The meshes are collected once per target and each frame only
+ // transforms its own box, which is what `setFromObject` did without the per-frame walk.
+ target.mesh.updateWorldMatrix(true, true);
+ const box = this.bounds.makeEmpty();
+ for (const mesh of this.#meshesOf(target)) {
+ if (mesh.geometry.boundingBox === null) mesh.geometry.computeBoundingBox();
+ const part = mesh.geometry.boundingBox;
+ if (part === null) continue;
+ box.union(this.#partBox.copy(part).applyMatrix4(mesh.matrixWorld));
+ }
const tall = MathUtils.clamp((box.max.y - box.min.y) / 1.8, 0.75, 1.25);
this.#spin += dt * 1.6;
const lift = LIFT + 0.05 * tall + Math.sin(this.#spin * 2.5) * 0.06;
@@ -118,12 +142,26 @@ export class LockOn {
Math.tan(MathUtils.degToRad(camera.fov / 2));
this.reticle.scale.setScalar(Math.max(tall, (MIN_SCREEN_SHARE * span) / ARROW_HEIGHT));
// Measured, not assumed: the gap from the target's top to the marker's own lowest point.
- const marker = this.#marker.setFromObject(this.reticle);
+ this.reticle.updateWorldMatrix(true, false);
+ const marker = this.#marker.copy(this.#reticleBox).applyMatrix4(this.reticle.matrixWorld);
this.markerLift = marker.min.y - box.max.y;
this.markerTop = marker.max.y;
}
}
+ /** The meshes a target's bounds cover: `setFromObject` walked them every frame, and a creature's
+ * mesh set never changes while it is locked. */
+ #meshesOf(target: ITarget): readonly Mesh[] {
+ if (this.#meshSource !== target) {
+ this.#meshes = [];
+ target.mesh.traverse((object) => {
+ if (object instanceof Mesh) this.#meshes.push(object);
+ });
+ this.#meshSource = target;
+ }
+ return this.#meshes;
+ }
+
/**
* The toggle's next target: with none locked, the same pick a hold makes; with one locked, the
* next farther hostile in range, and `undefined` (drop) after the farthest.
diff --git a/packages/create-threenative/templates/adventure/src/entities/Warden.ts b/packages/create-threenative/templates/adventure/src/entities/Warden.ts
index 51161a4d0d..61c743a51b 100644
--- a/packages/create-threenative/templates/adventure/src/entities/Warden.ts
+++ b/packages/create-threenative/templates/adventure/src/entities/Warden.ts
@@ -7,6 +7,9 @@ import type { ITarget } from "./LockOn.js";
const CAPSULE = { halfHeight: 0.55, radius: 0.75 } as const;
const FOOT_OFFSET = CAPSULE.halfHeight + CAPSULE.radius;
+/** The vector from the Warden to the hero: `update` runs every frame, so it uses one instance. */
+const toHero = new Vector3();
+
/** The Warden's numbers, in metres and seconds. Kit design values. */
export const WARDEN_FEEL = {
health: 3,
@@ -101,7 +104,7 @@ export class Warden implements ITarget {
const feel = WARDEN_FEEL;
this.#timer += dt;
this.#flash = Math.max(0, this.#flash - dt);
- const to = new Vector3().subVectors(hero.mesh.position, this.mesh.position).setY(0);
+ const to = toHero.subVectors(hero.mesh.position, this.mesh.position).setY(0);
let speed = 0;
switch (this.state) {
case "dormant":
diff --git a/packages/create-threenative/templates/adventure/src/level/Vale.ts b/packages/create-threenative/templates/adventure/src/level/Vale.ts
index 49f0ce14a3..a9c74501d7 100644
--- a/packages/create-threenative/templates/adventure/src/level/Vale.ts
+++ b/packages/create-threenative/templates/adventure/src/level/Vale.ts
@@ -2,6 +2,7 @@ import {
BoxGeometry,
CylinderGeometry,
Group,
+ MathUtils,
Mesh,
type Object3D,
PlaneGeometry,
@@ -17,6 +18,7 @@ import { surfaced } from "../render/surface.js";
export function makeRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
+ // eslint-disable-next-line no-restricted-syntax -- The clearing is authored against this mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it builds a different level.
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
@@ -52,7 +54,7 @@ export interface IVale {
const smooth = (a: number, b: number, t: number): number => {
const u = Math.min(1, Math.max(0, (t - a) / (b - a)));
- return u * u * (3 - 2 * u);
+ return MathUtils.smoothstep(u, 0, 1);
};
/** 0..1 rolling noise from two crossed sine sheets. */
const wave = (x: number, z: number, f: number): number =>
diff --git a/packages/create-threenative/templates/adventure/src/render/camera.ts b/packages/create-threenative/templates/adventure/src/render/camera.ts
index e877014b22..c2cada611c 100644
--- a/packages/create-threenative/templates/adventure/src/render/camera.ts
+++ b/packages/create-threenative/templates/adventure/src/render/camera.ts
@@ -335,8 +335,8 @@ export function setupCamera(
return yaw;
},
update(focus: Vector3, lock: ILockFrame | undefined, dt: number, watch?: Box3): void {
- const rate = 1 - Math.exp(-YAW_RATE * dt);
- const quick = 1 - Math.exp(-FRAME_RATE * dt);
+ const rate = MathUtils.damp(0, 1, YAW_RATE, dt);
+ const quick = MathUtils.damp(0, 1, FRAME_RATE, dt);
if (lock !== undefined) {
lock.bounds.getCenter(middle);
const line = Math.atan2(focus.x - middle.x, focus.z - middle.z);
diff --git a/packages/create-threenative/templates/adventure/src/render/foliage.ts b/packages/create-threenative/templates/adventure/src/render/foliage.ts
index c138c78b23..8c6310a910 100644
--- a/packages/create-threenative/templates/adventure/src/render/foliage.ts
+++ b/packages/create-threenative/templates/adventure/src/render/foliage.ts
@@ -24,6 +24,7 @@ import {
IcosahedronGeometry,
InstancedMesh,
type Material,
+ MathUtils,
Mesh,
Object3D,
OctahedronGeometry,
@@ -1006,7 +1007,7 @@ export function valeGround(
0.5 + 0.25 * (Math.sin(x * f + z * f * 0.55) + Math.sin(z * f * 1.4 - x * f * 0.37 + 1.3));
const smooth = (a: number, b: number, t: number): number => {
const u = Math.min(1, Math.max(0, (t - a) / (b - a)));
- return u * u * (3 - 2 * u);
+ return MathUtils.smoothstep(u, 0, 1);
};
const paintGround = (geometry: BufferGeometry): void => {
const position = geometry.attributes.position;
diff --git a/packages/create-threenative/templates/adventure/src/render/materials.ts b/packages/create-threenative/templates/adventure/src/render/materials.ts
index 6f32601649..caf6653593 100644
--- a/packages/create-threenative/templates/adventure/src/render/materials.ts
+++ b/packages/create-threenative/templates/adventure/src/render/materials.ts
@@ -15,6 +15,7 @@ import {
LinearFilter,
LinearMipmapLinearFilter,
type Material,
+ MathUtils,
MeshBasicMaterial,
MeshStandardMaterial,
NoColorSpace,
@@ -56,8 +57,8 @@ export function detailTexture(): DataTexture {
const tx = fx - x0;
const ty = fy - y0;
const at = (i: number, j: number): number => rng(((j % cells) * cells + (i % cells)) * 1.37);
- const sx = tx * tx * (3 - 2 * tx);
- const sy = ty * ty * (3 - 2 * ty);
+ const sx = MathUtils.smoothstep(tx, 0, 1);
+ const sy = MathUtils.smoothstep(ty, 0, 1);
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
const bottom = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * sx;
return top + (bottom - top) * sy;
diff --git a/packages/create-threenative/templates/adventure/src/render/touch-controls.ts b/packages/create-threenative/templates/adventure/src/render/touch-controls.ts
index 6a45dec9cb..df99eaf467 100644
--- a/packages/create-threenative/templates/adventure/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/adventure/src/render/touch-controls.ts
@@ -36,6 +36,9 @@ const STICK_RADIUS = 72;
const BUTTON_RADIUS = 46;
const EDGE = 36;
+/** The resting stick centre: `update` runs every frame, so it uses one instance. */
+const restPoint = new Vector2();
+
/** Where each button sits: a quarter-circle fan around the bottom-right corner. */
function buttonPoint(size: ITouchViewport, index: number): Vector2 {
const cx = size.width - EDGE - BUTTON_RADIUS;
@@ -84,7 +87,7 @@ export class TouchControls {
}
update(pointers: ReadonlyMap, size: ITouchViewport): ITouchInput {
- const rest = new Vector2(STICK_RADIUS + EDGE, size.height - STICK_RADIUS - EDGE);
+ const rest = restPoint.set(STICK_RADIUS + EDGE, size.height - STICK_RADIUS - EDGE);
let thumb: ITouchPointer | undefined;
for (const pointer of pointers.values()) {
if (pointer.position.x < size.width * 0.45 && pointer.position.y > size.height * 0.4) {
diff --git a/packages/create-threenative/templates/adventure/src/scenes/Play.ts b/packages/create-threenative/templates/adventure/src/scenes/Play.ts
index 2450d5aacf..69c6ae8754 100644
--- a/packages/create-threenative/templates/adventure/src/scenes/Play.ts
+++ b/packages/create-threenative/templates/adventure/src/scenes/Play.ts
@@ -609,7 +609,7 @@ export class Play extends Scene {
hero.mesh.position.z - f.x * 0.7 - f.z * 0.4 + Math.sin(orb.clock * 1.3) * 0.15,
);
if (companion.group.position.distanceTo(orbGoal) > 6) companion.group.position.copy(orbGoal);
- else companion.group.position.lerp(orbGoal, 1 - Math.exp(-dt * 4));
+ else companion.group.position.lerp(orbGoal, MathUtils.damp(0, 1, 4, dt));
const orbGap = companion.group.position.distanceTo(head);
orb.nearest = Math.min(orb.nearest, orbGap);
orb.farthest = Math.max(orb.farthest, orbGap);
@@ -774,6 +774,8 @@ export class Play extends Scene {
Object.assign(seen, { blocks: hero.shieldBlocks, clanks, hits: hero.slashHits });
warden.update(dt, hero);
for (const dummy of dummies) dummy.update(dt);
+ // Everything that moves has moved, so the lock's bounds and reticle are this frame's pose.
+ lock.place(camera, dt);
// A target falls inside Wren's slash, so count the fallen after everything has moved.
for (const fallen of targets) {
if (fallen.alive || counted.has(fallen)) continue;
diff --git a/packages/create-threenative/templates/collectathon/src/level/Course.ts b/packages/create-threenative/templates/collectathon/src/level/Course.ts
index 983700ca54..1b8c9fc092 100644
--- a/packages/create-threenative/templates/collectathon/src/level/Course.ts
+++ b/packages/create-threenative/templates/collectathon/src/level/Course.ts
@@ -1,4 +1,4 @@
-import { type IAssetLoader, WaveField } from "@threenative/core";
+import { type IAssetLoader, WaveField, createRandom } from "@threenative/core";
import {
Box3,
BoxGeometry,
@@ -228,13 +228,8 @@ function boulderGeometry(size: number, rng: () => number): BufferGeometry {
}
export function makeRng(seed: number): () => number {
- let state = seed >>> 0;
- return () => {
- state = (state * 1664525 + 1013904223) >>> 0;
- return state / 0x1_0000_0000;
- };
+ return createRandom(seed);
}
-
/**
* The island planted: drawn from `site` on a run that has nothing stored, and read back from
* `memo` on the next run of this build, so `buildPlanting` has the same placements either way.
diff --git a/packages/create-threenative/templates/collectathon/src/level/terrain.ts b/packages/create-threenative/templates/collectathon/src/level/terrain.ts
index 352168f8ea..6c811738d6 100644
--- a/packages/create-threenative/templates/collectathon/src/level/terrain.ts
+++ b/packages/create-threenative/templates/collectathon/src/level/terrain.ts
@@ -1,4 +1,5 @@
import { Heightfield } from "@threenative/core/world";
+import { MathUtils } from "three";
/** A rectangle [x min, x max, z min, z max] or a disc [x, z, radius] of level ground at y = 0. */
export type LevelZone =
@@ -35,7 +36,7 @@ const HEADLAND = { x: 2, z: -60, rx: 30, rz: 26 } as const;
const clamp01 = (t: number): number => Math.min(1, Math.max(0, t));
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
diff --git a/packages/create-threenative/templates/collectathon/src/render/camera.ts b/packages/create-threenative/templates/collectathon/src/render/camera.ts
index 871ed5265c..bf5bc9fe42 100644
--- a/packages/create-threenative/templates/collectathon/src/render/camera.ts
+++ b/packages/create-threenative/templates/collectathon/src/render/camera.ts
@@ -151,10 +151,10 @@ export function createOrbitCamera(camera: PerspectiveCamera): IOrbitCamera {
// Running at the camera does not spin it round: only travel within 120° of the view swings it.
if (Math.abs(turn) < (Math.PI * 2) / 3) {
const rate = CAMERA_RIG.swingRate * Math.min(1, speed / 9.6);
- yaw += turn * (1 - Math.exp(-dt * rate));
+ yaw = MathUtils.damp(yaw, yaw + turn, rate, dt);
}
}
- place(target, probe, 1 - Math.exp(-dt * CAMERA_RIG.followRate));
+ place(target, probe, MathUtils.damp(0, 1, CAMERA_RIG.followRate, dt));
},
debug() {
return { closest, cuts, distance, elevation, peakElevation, pulledIn, tilt, yaw };
diff --git a/packages/create-threenative/templates/collectathon/src/render/ground.ts b/packages/create-threenative/templates/collectathon/src/render/ground.ts
index 0a59ae751f..45b7f190ae 100644
--- a/packages/create-threenative/templates/collectathon/src/render/ground.ts
+++ b/packages/create-threenative/templates/collectathon/src/render/ground.ts
@@ -3,7 +3,7 @@
// The island's ground: one terrain mesh whose vertex attributes say where grass, worn grass, the
// sandy dirt path, beach sand and rock lie, by height, slope and distance to the path. The textures
// and the blend are `groundMaterial` in surface.ts.
-import { BufferAttribute, BufferGeometry, Color, Mesh } from "three";
+import { BufferAttribute, BufferGeometry, Color, MathUtils, Mesh } from "three";
import { lit, seeded } from "./materials.js";
import { C } from "./palette.js";
@@ -29,8 +29,8 @@ export function valueNoise(cells: number, seed: number): (u: number, v: number)
const y = v * cells;
const i = Math.floor(x);
const j = Math.floor(y);
- const fx = (x - i) * (x - i) * (3 - 2 * (x - i));
- const fy = (y - j) * (y - j) * (3 - 2 * (y - j));
+ const fx = MathUtils.smoothstep(x - i, 0, 1);
+ const fy = MathUtils.smoothstep(y - j, 0, 1);
const top = at(i, j) + (at(i + 1, j) - at(i, j)) * fx;
const bottom = at(i, j + 1) + (at(i + 1, j + 1) - at(i, j + 1)) * fx;
return top + (bottom - top) * fy;
@@ -39,7 +39,7 @@ export function valueNoise(cells: number, seed: number): (u: number, v: number)
const smooth = (from: number, to: number, value: number): number => {
const t = Math.min(1, Math.max(0, (value - from) / (to - from)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
/**
diff --git a/packages/create-threenative/templates/collectathon/src/render/hero.ts b/packages/create-threenative/templates/collectathon/src/render/hero.ts
index f671ed7995..abdc852dd1 100644
--- a/packages/create-threenative/templates/collectathon/src/render/hero.ts
+++ b/packages/create-threenative/templates/collectathon/src/render/hero.ts
@@ -366,7 +366,7 @@ export function createHero(): IHero {
update(pose: IHeroPose): void {
time += pose.dt;
const run = Math.min(1, pose.speed / 9.6);
- const k = 1 - Math.exp(-pose.dt * 18);
+ const k = MathUtils.damp(0, 1, 18, pose.dt);
let pitch = 0;
let roll = 0;
let squash = 1;
diff --git a/packages/create-threenative/templates/collectathon/src/render/materials.ts b/packages/create-threenative/templates/collectathon/src/render/materials.ts
index 5b636a6f53..4b5f864e72 100644
--- a/packages/create-threenative/templates/collectathon/src/render/materials.ts
+++ b/packages/create-threenative/templates/collectathon/src/render/materials.ts
@@ -186,7 +186,6 @@ export function seeded(seed: number): () => number {
return state / 0x1_0000_0000;
};
}
-
/** The surfaces the island is made of, resolved once. */
export function createMaterials() {
return {
diff --git a/packages/create-threenative/templates/collectathon/src/render/sky.ts b/packages/create-threenative/templates/collectathon/src/render/sky.ts
index 0cec31563c..1b32526a0f 100644
--- a/packages/create-threenative/templates/collectathon/src/render/sky.ts
+++ b/packages/create-threenative/templates/collectathon/src/render/sky.ts
@@ -11,6 +11,7 @@ import {
EquirectangularReflectionMapping,
FogExp2,
LinearFilter,
+ MathUtils,
RGBAFormat,
SRGBColorSpace,
type Scene,
@@ -39,8 +40,8 @@ const LATTICE = (() => {
function noise(x: number, y: number): number {
const i = Math.floor(x);
const j = Math.floor(y);
- const fx = (x - i) * (x - i) * (3 - 2 * (x - i));
- const fy = (y - j) * (y - j) * (3 - 2 * (y - j));
+ const fx = MathUtils.smoothstep(x - i, 0, 1);
+ const fy = MathUtils.smoothstep(y - j, 0, 1);
const i0 = i & 255;
const i1 = (i + 1) & 255;
const j0 = (j & 255) << 8;
@@ -79,7 +80,7 @@ function density(x: number, y: number): number {
const smooth = (from: number, to: number, value: number): number => {
const t = Math.min(1, Math.max(0, (value - from) / (to - from)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
/**
diff --git a/packages/create-threenative/templates/kart/src/entities/Kart.ts b/packages/create-threenative/templates/kart/src/entities/Kart.ts
index 467be28b1e..27de2bc1cc 100644
--- a/packages/create-threenative/templates/kart/src/entities/Kart.ts
+++ b/packages/create-threenative/templates/kart/src/entities/Kart.ts
@@ -1,3 +1,4 @@
+import { MathUtils } from "three";
import { type ICourse, SURFACE_DRAG, type Surface } from "../track/Course.js";
/**
@@ -327,7 +328,7 @@ export class Kart {
Math.max(0, 1 - resist) *
(1 + this.boost + this.miniTurbo + this.catchUp);
if (brake && this.throttle === 0 && this.speed < 0.5) target = -f.reverse;
- this.speed += (target - this.speed) * (1 - Math.exp(-ticks / f.lagTicks));
+ this.speed = MathUtils.damp(this.speed, target, 1 / f.lagTicks, ticks);
}
// Hop and drift. A press while grounded hops; with the stick held and enough speed the hop
diff --git a/packages/create-threenative/templates/kart/src/render/course.ts b/packages/create-threenative/templates/kart/src/render/course.ts
index 0a05aa475e..86772ffd18 100644
--- a/packages/create-threenative/templates/kart/src/render/course.ts
+++ b/packages/create-threenative/templates/kart/src/render/course.ts
@@ -15,6 +15,7 @@ import {
Group,
IcosahedronGeometry,
InstancedMesh,
+ MathUtils,
Matrix4,
Mesh,
MeshStandardMaterial,
@@ -521,7 +522,7 @@ export function buildCourseView(course: ICourse, seed = 734): ICourseView {
function smooth(edge0: number, edge1: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
}
/** True when x/z is at least `clearance` metres from every sample of the line. */
diff --git a/packages/create-threenative/templates/kart/src/track/Course.ts b/packages/create-threenative/templates/kart/src/track/Course.ts
index 3af0e70561..bee19aaba7 100644
--- a/packages/create-threenative/templates/kart/src/track/Course.ts
+++ b/packages/create-threenative/templates/kart/src/track/Course.ts
@@ -10,6 +10,8 @@
*/
/** Metres between samples of the centre line. */
+import { MathUtils } from "three";
+
export const STEP = 1;
/** The lateral profile, in metres from the centre line. */
@@ -69,7 +71,7 @@ function plateau(s: number, at: number, length: number, flat: number, ramp: numb
function smooth(edge0: number, edge1: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
}
export interface ICourse {
diff --git a/packages/create-threenative/templates/minimal/src/render/camera.ts b/packages/create-threenative/templates/minimal/src/render/camera.ts
index bff5d15f6f..fe09af9338 100644
--- a/packages/create-threenative/templates/minimal/src/render/camera.ts
+++ b/packages/create-threenative/templates/minimal/src/render/camera.ts
@@ -1,5 +1,5 @@
// Generated for you. Camera framing is yours to edit.
-import { type PerspectiveCamera, Vector3 } from "three";
+import { MathUtils, type PerspectiveCamera, Vector3 } from "three";
/** Where the camera sits relative to the player's body centre: up and behind, over the shoulder. */
const OFFSET = new Vector3(0.6, 1.4, 4.6);
@@ -51,7 +51,7 @@ export function followCamera(
const wall = probe(_aim, _desired);
if (wall === undefined) {
const gap = camera.position.distanceTo(_desired);
- const step = Math.max(gap * (1 - Math.exp(-dt * 8)), SETTLE_SPEED * dt);
+ const step = Math.max(gap * MathUtils.damp(0, 1, 8, dt), SETTLE_SPEED * dt);
if (gap <= 2 * step) camera.position.copy(_desired);
else camera.position.lerp(_desired, step / gap);
} else {
diff --git a/packages/create-threenative/templates/minimal/src/render/clouds.ts b/packages/create-threenative/templates/minimal/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/minimal/src/render/clouds.ts
+++ b/packages/create-threenative/templates/minimal/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/minimal/src/render/materials.ts b/packages/create-threenative/templates/minimal/src/render/materials.ts
index a7a5f172f0..7cd27b338c 100644
--- a/packages/create-threenative/templates/minimal/src/render/materials.ts
+++ b/packages/create-threenative/templates/minimal/src/render/materials.ts
@@ -11,6 +11,7 @@ import {
Float32BufferAttribute,
FrontSide,
LinearMipmapLinearFilter,
+ MathUtils,
MeshStandardMaterial,
RepeatWrapping,
SRGBColorSpace,
@@ -84,8 +85,8 @@ function grainNormalTexture(size = 128): DataTexture {
const y0 = Math.floor(v);
const fx = u - x0;
const fy = v - y0;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
+ const sx = MathUtils.smoothstep(fx, 0, 1);
+ const sy = MathUtils.smoothstep(fy, 0, 1);
const at = (i: number, j: number): number =>
values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
diff --git a/packages/create-threenative/templates/minimal/src/render/touch-controls.ts b/packages/create-threenative/templates/minimal/src/render/touch-controls.ts
index 34eb30206b..2ad759de86 100644
--- a/packages/create-threenative/templates/minimal/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/minimal/src/render/touch-controls.ts
@@ -15,6 +15,9 @@ const MOVE_RADIUS = 72;
const BUTTON_RADIUS = 62;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -111,13 +114,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#jump.material = jump ? this.#activeMaterial : this.#idleMaterial;
this.#input.jumpPressed = jump && !this.#wasJump;
this.#wasJump = jump;
diff --git a/packages/create-threenative/templates/platformer/src/level/Stage.ts b/packages/create-threenative/templates/platformer/src/level/Stage.ts
index 5bba4f80f2..ec3a672094 100644
--- a/packages/create-threenative/templates/platformer/src/level/Stage.ts
+++ b/packages/create-threenative/templates/platformer/src/level/Stage.ts
@@ -1,3 +1,4 @@
+import { createRandom } from "@threenative/core";
import {
BoxGeometry,
ConeGeometry,
@@ -18,13 +19,8 @@ import { waterfall } from "../render/waterfall.js";
/** Deterministic, so the route is the same course every run. */
export function makeRng(seed = 90210): () => number {
- let state = seed >>> 0;
- return () => {
- state = (state * 1664525 + 1013904223) >>> 0;
- return state / 4294967296;
- };
+ return createRandom(seed);
}
-
export interface IStage {
/** The one root to add to the scene and hand to `buildStaticColliders`. */
readonly group: Group;
diff --git a/packages/create-threenative/templates/puzzle/src/entities/Warden.ts b/packages/create-threenative/templates/puzzle/src/entities/Warden.ts
index 02a0345839..3037a97119 100644
--- a/packages/create-threenative/templates/puzzle/src/entities/Warden.ts
+++ b/packages/create-threenative/templates/puzzle/src/entities/Warden.ts
@@ -1,6 +1,6 @@
import type { ICtx } from "@threenative/core";
import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics";
-import { Group, Vector3 } from "three";
+import { Group, MathUtils, Vector3 } from "three";
import { wardenFigure } from "../render/warden.js";
import type { GameState } from "../state.js";
import { WARDEN_MASK } from "./Crate.js";
@@ -90,12 +90,12 @@ export class Warden {
// Shortest-arc turn, framerate independent, so the figure faces the shove rather than
// snapping between the four arrow directions.
const delta = Math.atan2(Math.sin(target - this.#facing), Math.cos(target - this.#facing));
- this.#facing += delta * (1 - Math.exp(-dt / 0.06));
+ this.#facing = MathUtils.damp(this.#facing, this.#facing + delta, 1 / 0.06, dt);
this.#figure.rotation.y = this.#facing;
}
// Lean into the push. Purely cosmetic, and it is most of what makes the shove read.
const lean = Math.min(0.28, Math.hypot(move.x, move.z) * 0.28);
- this.#figure.rotation.x += (lean - this.#figure.rotation.x) * (1 - Math.exp(-dt / 0.09));
+ this.#figure.rotation.x = MathUtils.damp(this.#figure.rotation.x, lean, 1 / 0.09, dt);
}
teleport(position: { readonly x: number; readonly y: number; readonly z: number }): void {
diff --git a/packages/create-threenative/templates/puzzle/src/render/camera.ts b/packages/create-threenative/templates/puzzle/src/render/camera.ts
index a34910bf17..028ab3b11c 100644
--- a/packages/create-threenative/templates/puzzle/src/render/camera.ts
+++ b/packages/create-threenative/templates/puzzle/src/render/camera.ts
@@ -7,7 +7,7 @@
// It still breathes. `settle` eases the framing in over the opening drop, and `nudge` lets the
// shot drift a fraction of a metre toward the warden so the picture is not completely static.
import type { PerspectiveCamera, Vector3 } from "three";
-import { Vector3 as Vec3 } from "three";
+import { MathUtils, Vector3 as Vec3 } from "three";
export const VAULT_SHOT = {
fov: 31,
@@ -64,7 +64,7 @@ export function createVaultCamera(camera: PerspectiveCamera): IVaultCamera {
desired.copy(VAULT_SHOT.position).add(aim);
// 1 - e^(-dt/tau) is the framerate-independent form of a lerp: the same second of drift at
// 30 fps and at 120 fps ends in the same place.
- camera.position.lerp(desired, 1 - Math.exp(-dt / 0.9));
+ camera.position.lerp(desired, MathUtils.damp(0, 1, 1 / 0.9, dt));
camera.lookAt(currentAim);
};
diff --git a/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts b/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts
index bb6cd6978c..f2552278ea 100644
--- a/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/puzzle/src/render/touch-controls.ts
@@ -14,6 +14,9 @@ import { palette } from "./palette.js";
const MOVE_RADIUS = 72;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -101,13 +104,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#layout(size);
return this.#input;
}
diff --git a/packages/create-threenative/templates/racing/src/render/camera.ts b/packages/create-threenative/templates/racing/src/render/camera.ts
index f0c3eb9009..4433da1f49 100644
--- a/packages/create-threenative/templates/racing/src/render/camera.ts
+++ b/packages/create-threenative/templates/racing/src/render/camera.ts
@@ -54,7 +54,7 @@ export function chaseCamera(
desired.copy(target).addScaledVector(behind, -EYE_BACK);
desired.y += EYE_UP;
if (dt >= 1) camera.position.copy(desired);
- else camera.position.lerp(desired, 1 - Math.exp(-dt / 0.2));
+ else camera.position.lerp(desired, MathUtils.damp(0, 1, 5, dt));
// Look ahead along the same travel direction, so a fast car sees the corner it is entering
// rather than the road immediately under its own nose.
const speed = Math.hypot(velocity.x, velocity.z);
diff --git a/packages/create-threenative/templates/racing/src/render/clouds.ts b/packages/create-threenative/templates/racing/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/racing/src/render/clouds.ts
+++ b/packages/create-threenative/templates/racing/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/racing/src/render/materials.ts b/packages/create-threenative/templates/racing/src/render/materials.ts
index 53429ea2c1..1a8ada0e3b 100644
--- a/packages/create-threenative/templates/racing/src/render/materials.ts
+++ b/packages/create-threenative/templates/racing/src/render/materials.ts
@@ -12,6 +12,7 @@ import {
DataTexture,
Float32BufferAttribute,
LinearMipmapLinearFilter,
+ MathUtils,
MeshStandardMaterial,
RepeatWrapping,
SRGBColorSpace,
@@ -31,7 +32,7 @@ function noise(x: number, y: number, seed: number): number {
/** Smoothstep, so the rubber band has a soft edge instead of a stripe painted across the road. */
function falloff(value: number, edge: number): number {
const t = Math.min(Math.max((Math.abs(value) - edge) / 0.28, 0), 1);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
}
function rgbaTexture(
@@ -121,8 +122,8 @@ function grainNormalTexture(size = 128): DataTexture {
const y0 = Math.floor(v);
const fx = u - x0;
const fy = v - y0;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
+ const sx = MathUtils.smoothstep(fx, 0, 1);
+ const sy = MathUtils.smoothstep(fy, 0, 1);
const at = (i: number, j: number): number =>
values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
diff --git a/packages/create-threenative/templates/racing/src/render/touch-controls.ts b/packages/create-threenative/templates/racing/src/render/touch-controls.ts
index 063aaa179a..111af94101 100644
--- a/packages/create-threenative/templates/racing/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/racing/src/render/touch-controls.ts
@@ -15,6 +15,9 @@ const MOVE_RADIUS = 72;
const BUTTON_RADIUS = 58;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -122,13 +125,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#boost.material = boost ? this.#activeMaterial : this.#idleMaterial;
this.#brake.material = brake ? this.#activeMaterial : this.#idleMaterial;
this.#input.boostPressed = boost && !this.#wasBoost;
diff --git a/packages/create-threenative/templates/racing/src/track/circuit.ts b/packages/create-threenative/templates/racing/src/track/circuit.ts
index e3bf84ad20..d872a741dd 100644
--- a/packages/create-threenative/templates/racing/src/track/circuit.ts
+++ b/packages/create-threenative/templates/racing/src/track/circuit.ts
@@ -1,4 +1,4 @@
-import { CatmullRomCurve3, Vector3 } from "three";
+import { CatmullRomCurve3, MathUtils, Vector3 } from "three";
/**
* The circuit, once.
@@ -126,7 +126,7 @@ function bankFade(radius: number): number {
if (!Number.isFinite(radius) || radius >= BANK_FULL_RADIUS) return 1;
if (radius <= BANK_NONE_RADIUS) return 0;
const fade = (radius - BANK_NONE_RADIUS) / (BANK_FULL_RADIUS - BANK_NONE_RADIUS);
- return fade * fade * (3 - 2 * fade);
+ return MathUtils.smoothstep(fade, 0, 1);
}
/**
@@ -435,7 +435,7 @@ export function terrainHeight(x: number, z: number): number {
const plane = centre.point.y - ROAD_LIFT - projection.lateral * centre.bank;
if (lateral <= CORRIDOR) return plane;
const blend = (lateral - CORRIDOR) / (BLEND_END - CORRIDOR);
- return plane + (open - plane) * blend * blend * (3 - 2 * blend);
+ return plane + (open - plane) * MathUtils.smoothstep(blend, 0, 1);
}
/** Where the start/finish line, the sectors and the grid are, as a fraction of the lap. */
diff --git a/packages/create-threenative/templates/rain/src/render/noise-volume.ts b/packages/create-threenative/templates/rain/src/render/noise-volume.ts
index decd04a291..3c35447a82 100644
--- a/packages/create-threenative/templates/rain/src/render/noise-volume.ts
+++ b/packages/create-threenative/templates/rain/src/render/noise-volume.ts
@@ -4,6 +4,7 @@
import {
Data3DTexture,
LinearFilter,
+ MathUtils,
NoColorSpace,
RGBAFormat,
RepeatWrapping,
@@ -76,7 +77,7 @@ function sampleLattice(lattice: ILattice, x: number, y: number, z: number): numb
values[((c % n) * n + (b % n)) * n + (a % n)] as number;
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
const blend = (fx: number, fy: number, fz: number): number => {
- const smooth = (f: number): number => f * f * (3 - 2 * f);
+ const smooth = (f: number): number => MathUtils.smoothstep(f, 0, 1);
const tx = smooth(fx);
const ty = smooth(fy);
const tz = smooth(fz);
diff --git a/packages/create-threenative/templates/rain/src/state.ts b/packages/create-threenative/templates/rain/src/state.ts
index 4bd464ccfd..73353fe009 100644
--- a/packages/create-threenative/templates/rain/src/state.ts
+++ b/packages/create-threenative/templates/rain/src/state.ts
@@ -6,6 +6,8 @@
* they live here rather than in a package.
*/
+import { MathUtils } from "three";
+
export const WEATHER_KEYS = ["rain", "cloud", "wind", "fog", "exposure", "wet"] as const;
export type WeatherKey = (typeof WEATHER_KEYS)[number];
@@ -74,7 +76,7 @@ export function sanitizeWeather(
/** Exponential ease towards the target, independent of frame rate because `dt` is real seconds. */
export function easeWeather(current: Weather, target: Weather, dt: number): Weather {
- const k = 1 - Math.exp(-dt / EASE_TAU);
+ const k = MathUtils.damp(0, 1, 1 / EASE_TAU, dt);
const next = { ...current };
for (const key of WEATHER_KEYS) next[key] = current[key] + (target[key] - current[key]) * k;
return next;
diff --git a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx
index 588e60e07d..e45372ac0f 100644
--- a/packages/create-threenative/templates/rain/src/ui/GameUi.tsx
+++ b/packages/create-threenative/templates/rain/src/ui/GameUi.tsx
@@ -154,7 +154,7 @@ function RainInterface() {
return;
qualityChosen.current = true;
// An operator asking for high on a phone or software adapter keeps that explicit choice.
- const asked = automationRequest(window.location.search).quality;
+ const asked = automationRequest().quality;
if (asked !== undefined) {
send("setQuality", asked);
return;
diff --git a/packages/create-threenative/templates/rain/src/ui/automation.ts b/packages/create-threenative/templates/rain/src/ui/automation.ts
index fd5e132ea7..dd32294b22 100644
--- a/packages/create-threenative/templates/rain/src/ui/automation.ts
+++ b/packages/create-threenative/templates/rain/src/ui/automation.ts
@@ -7,10 +7,11 @@
* whole mapping. A name in neither list throws: a typo must not silently pick a tier and report
* success.
*
- * Query reading takes a search string rather than reaching for `location`, so the rules are the
- * same on every target and a test can hold one.
+ * Query reading goes through the engine's debug seam when no search string is given, so the same
+ * rule reads the URL on the web and `TN_DEBUG_*` on a native launch; a test can still hold one.
*/
+import { debugFlag, debugValue } from "@threenative/core";
import type { QualityName } from "../state.js";
/** The four engine tiers, plus the four names the source study published for them. */
@@ -48,13 +49,14 @@ export interface IAutomationRequest {
* What the query asks for. An unreadable tier is named in `rejectedQuality` instead of thrown: a
* URL is a place somebody typed, and the page still has to open.
*/
-export function automationRequest(search: string): IAutomationRequest {
- const query = new URLSearchParams(search);
- const asked = query.get("quality");
- if (asked === null) return { still: query.has("still") };
+export function automationRequest(search?: string): IAutomationRequest {
+ const query = search === undefined ? undefined : new URLSearchParams(search);
+ const asked = query === undefined ? debugValue("quality") : query.get("quality");
+ const still = query === undefined ? debugFlag("still") : query.has("still");
+ if (asked === null || asked === undefined) return { still };
try {
- return { still: query.has("still"), quality: qualityTier(asked) };
+ return { still, quality: qualityTier(asked) };
} catch {
- return { still: query.has("still"), rejectedQuality: asked };
+ return { still, rejectedQuality: asked };
}
}
diff --git a/packages/create-threenative/templates/rain/src/ui/tempest.ts b/packages/create-threenative/templates/rain/src/ui/tempest.ts
index 143f7c9f14..96f50a64d0 100644
--- a/packages/create-threenative/templates/rain/src/ui/tempest.ts
+++ b/packages/create-threenative/templates/rain/src/ui/tempest.ts
@@ -13,7 +13,7 @@
* browser APIs. `src/game.ts` stays portable: this sends the same validated intents the buttons do.
*/
-import type { IGame } from "@threenative/core";
+import { type IGame, debugFlag } from "@threenative/core";
import { connectUiBridge, sendUiIntent, subscribeUiState } from "@threenative/core/ui-layer";
import type { GameState, PresetName, WeatherKey } from "../state.js";
import type { ICaptureResult, ISceneSizes } from "./Hud.js";
@@ -78,15 +78,14 @@ export function installTempest(game: IGame): ITempestApi {
const send = (intent: IntentName, payload?: unknown): void => {
sendUiIntent(bridge, intent, payload);
};
- const query = new URLSearchParams(window.location.search);
- const request = automationRequest(window.location.search);
+ const request = automationRequest();
if (request.rejectedQuality !== undefined) {
say(
`?quality=${request.rejectedQuality} is not a tier: expected low, balanced, high or cinematic`,
);
}
if (request.quality !== undefined) send("setQuality", request.quality);
- if (query.has("offline")) {
+ if (debugFlag("offline")) {
// The source's offline WebGL2 adapter stood in for a Three.js build it could not load from a
// CDN. This build installs Three locally and never reaches for one, so there is no fallback
// to select and nothing here pretends otherwise.
diff --git a/packages/create-threenative/templates/rts/src/sim/terrain.ts b/packages/create-threenative/templates/rts/src/sim/terrain.ts
index a74b6753cf..75bebeffa2 100644
--- a/packages/create-threenative/templates/rts/src/sim/terrain.ts
+++ b/packages/create-threenative/templates/rts/src/sim/terrain.ts
@@ -102,6 +102,7 @@ export const ROCK_FIELDS: readonly (readonly [number, number, number])[] = [
const smooth01 = (t: number): number => {
const c = Math.max(0, Math.min(1, t));
+ // eslint-disable-next-line no-restricted-syntax -- a sim module must not import three (rts-runtime-cost.spec), so the inline form stays here.
return c * c * (3 - 2 * c);
};
diff --git a/packages/create-threenative/templates/rts/src/sim/types.ts b/packages/create-threenative/templates/rts/src/sim/types.ts
index cdf6e77865..646a5672fe 100644
--- a/packages/create-threenative/templates/rts/src/sim/types.ts
+++ b/packages/create-threenative/templates/rts/src/sim/types.ts
@@ -603,6 +603,7 @@ export function seeded(seed = 17): () => number {
let s = seed;
return () => {
s |= 0;
+ // eslint-disable-next-line no-restricted-syntax -- Replays depend on this exact mulberry32 stream; the engine's seeded RNG is an LCG, so swapping it changes every recorded match.
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
diff --git a/packages/create-threenative/templates/runner/src/render/clouds.ts b/packages/create-threenative/templates/runner/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/runner/src/render/clouds.ts
+++ b/packages/create-threenative/templates/runner/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/runner/src/render/materials.ts b/packages/create-threenative/templates/runner/src/render/materials.ts
index 21354018ef..941e3a708b 100644
--- a/packages/create-threenative/templates/runner/src/render/materials.ts
+++ b/packages/create-threenative/templates/runner/src/render/materials.ts
@@ -9,6 +9,7 @@ import {
DataTexture,
Float32BufferAttribute,
LinearMipmapLinearFilter,
+ MathUtils,
MeshStandardMaterial,
RepeatWrapping,
SRGBColorSpace,
@@ -82,8 +83,8 @@ function grainNormalTexture(size = 128): DataTexture {
const y0 = Math.floor(v);
const fx = u - x0;
const fy = v - y0;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
+ const sx = MathUtils.smoothstep(fx, 0, 1);
+ const sy = MathUtils.smoothstep(fy, 0, 1);
const at = (i: number, j: number): number =>
values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
diff --git a/packages/create-threenative/templates/runner/src/render/touch-controls.ts b/packages/create-threenative/templates/runner/src/render/touch-controls.ts
index 4e57eb24e1..03ffe52228 100644
--- a/packages/create-threenative/templates/runner/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/runner/src/render/touch-controls.ts
@@ -15,6 +15,9 @@ const MOVE_RADIUS = 72;
const BUTTON_RADIUS = 58;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -122,13 +125,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#left.material = left ? this.#activeMaterial : this.#idleMaterial;
this.#right.material = right ? this.#activeMaterial : this.#idleMaterial;
this.#input.leftPressed = left && !this.#wasLeft;
diff --git a/packages/create-threenative/templates/runner/src/scenes/Run.ts b/packages/create-threenative/templates/runner/src/scenes/Run.ts
index 64fcd23664..24177651c5 100644
--- a/packages/create-threenative/templates/runner/src/scenes/Run.ts
+++ b/packages/create-threenative/templates/runner/src/scenes/Run.ts
@@ -205,7 +205,9 @@ export class Run extends Scene {
const previous = frameCtx.state.getState();
const next: GameState = {
chunks: track.built,
- distance,
+ // Whole metres: the HUD shows the floor, and a fractional distance would republish every
+ // frame. The store skips a key it already holds, so this is the only stillness it needs.
+ distance: Math.floor(distance),
lane: runner.lane,
nearMisses,
paused: previous.paused,
@@ -213,14 +215,7 @@ export class Run extends Scene {
status,
uiReady: previous.uiReady,
};
- if (
- next.chunks !== previous.chunks ||
- next.lane !== previous.lane ||
- next.nearMisses !== previous.nearMisses ||
- next.status !== previous.status ||
- Math.floor(next.distance) !== Math.floor(previous.distance)
- )
- frameCtx.state.set(next);
+ frameCtx.state.set(next);
};
}
diff --git a/packages/create-threenative/templates/sailing/src/render/touch-controls.ts b/packages/create-threenative/templates/sailing/src/render/touch-controls.ts
index 6f0ba0d96e..3f47c637d2 100644
--- a/packages/create-threenative/templates/sailing/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/sailing/src/render/touch-controls.ts
@@ -14,6 +14,9 @@ import { palette } from "./palette.js";
const MOVE_RADIUS = 72;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -100,13 +103,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#moveKnob.material = this.#hasMoveAnchor ? this.#activeMaterial : this.#idleMaterial;
this.#layout(size);
return this.#input;
diff --git a/packages/create-threenative/templates/shooter/src/render/clouds.ts b/packages/create-threenative/templates/shooter/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/shooter/src/render/clouds.ts
+++ b/packages/create-threenative/templates/shooter/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/snow/src/entities/Explorer.ts b/packages/create-threenative/templates/snow/src/entities/Explorer.ts
index 2a8507518a..9901dc6405 100644
--- a/packages/create-threenative/templates/snow/src/entities/Explorer.ts
+++ b/packages/create-threenative/templates/snow/src/entities/Explorer.ts
@@ -1,6 +1,6 @@
import type { SnowField } from "@threenative/core/world";
import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics";
-import { Object3D } from "three";
+import { MathUtils, Object3D } from "three";
import { BOOT_AREA, bootPrint } from "../render/bootPrint.js";
import { ExplorerModel, type IFootPose } from "../render/explorer.js";
import type { SnowMaterials } from "../render/materials.js";
@@ -45,12 +45,6 @@ export const STANCE = 0.2;
const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
-const damp = (a: number, b: number, rate: number, dt: number) =>
- lerp(a, b, 1 - Math.exp(-rate * Math.max(0, dt)));
-const smoothstep = (edge0: number, edge1: number, value: number) => {
- const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
- return t * t * (3 - 2 * t);
-};
/** Walking pace through powder: deeper, softer snow is slower; packed snow is faster. */
function paceThrough(depth: number, compaction: number, running: boolean): number {
@@ -146,8 +140,8 @@ export class Explorer {
const length = Math.hypot(move.x, move.z);
const scale = length > 1 ? 1 / length : 1;
const moving = length > 0.01;
- this.#velocity.x = damp(this.#velocity.x, move.x * scale * pace, moving ? 5 : 8, dt);
- this.#velocity.z = damp(this.#velocity.z, move.z * scale * pace, moving ? 5 : 8, dt);
+ this.#velocity.x = MathUtils.damp(this.#velocity.x, move.x * scale * pace, moving ? 5 : 8, dt);
+ this.#velocity.z = MathUtils.damp(this.#velocity.z, move.z * scale * pace, moving ? 5 : 8, dt);
if (!moving && Math.hypot(this.#velocity.x, this.#velocity.z) < 0.025) {
this.#velocity.x = 0;
this.#velocity.z = 0;
@@ -159,7 +153,7 @@ export class Explorer {
if (this.speed > 0.05) {
const desired = Math.atan2(this.#velocity.x, this.#velocity.z);
const turn = Math.atan2(Math.sin(desired - this.heading), Math.cos(desired - this.heading));
- this.heading += turn * (1 - Math.exp(-7 * dt));
+ this.heading = MathUtils.damp(this.heading, this.heading + turn, 7, dt);
}
this.#cycle += travelled / (running ? 1.22 : 1.04);
this.#stepFeet(dt, running, hardness);
@@ -169,7 +163,7 @@ export class Explorer {
this.speed > 0.02
? Math.cos(this.#cycle * Math.PI * 4) * 0.016
: Math.sin(this.#elapsed * 1.8) * 0.003;
- this.position.y = damp(
+ this.position.y = MathUtils.damp(
this.position.y,
terrainHeight(anchor.x, anchor.z) + snow.depth - sink + bob,
14,
@@ -221,7 +215,7 @@ export class Explorer {
progress = Math.max(foot.progress, clamp(foot.progress + foot.stop, 0, 1));
}
foot.progress = progress;
- const eased = smoothstep(0, 1, progress);
+ const eased = MathUtils.smoothstep(progress, 0, 1);
foot.x = lerp(foot.from.x, foot.to.x, eased);
foot.z = lerp(foot.from.z, foot.to.z, eased);
const lift =
diff --git a/packages/create-threenative/templates/snow/src/render/bootPrint.ts b/packages/create-threenative/templates/snow/src/render/bootPrint.ts
index ad6c1adcfe..a4e96a30c9 100644
--- a/packages/create-threenative/templates/snow/src/render/bootPrint.ts
+++ b/packages/create-threenative/templates/snow/src/render/bootPrint.ts
@@ -2,6 +2,8 @@
// Change the sole, the tread or the rim here; `SnowField` only presses whatever shape it is given.
/** One sample of a contact profile, in the shape `SnowField.stamp` reads. */
+import { MathUtils } from "three";
+
interface IPrintSample {
readonly bank: number;
readonly coverage: number;
@@ -14,7 +16,7 @@ const NOTHING: IPrintSample = { bank: 0, coverage: 0, disturbance: 0, relief: 0,
function smoothstep(edge0: number, edge1: number, value: number): number {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
}
/** The sole's area, m². Load over this is the pressure a planted boot puts on the snow. */
diff --git a/packages/create-threenative/templates/snow/src/render/camera.ts b/packages/create-threenative/templates/snow/src/render/camera.ts
index 91eaeebedf..f368b1bb6a 100644
--- a/packages/create-threenative/templates/snow/src/render/camera.ts
+++ b/packages/create-threenative/templates/snow/src/render/camera.ts
@@ -1,5 +1,6 @@
// Generated for you. Framing is a game-owned decision.
import type { PerspectiveCamera } from "three";
+import { MathUtils } from "three";
import type { CameraView } from "../state.js";
export function setupCamera(camera: PerspectiveCamera): void {
@@ -62,8 +63,8 @@ export class OrbitFollow {
ground: (x: number, z: number) => number,
dt: number,
): void {
- const ease = 1 - Math.exp(-dt * 4.5);
- const settle = 1 - Math.exp(-dt * 5);
+ const ease = MathUtils.damp(0, 1, 4.5, dt);
+ const settle = MathUtils.damp(0, 1, 5, dt);
this.#target.x += (goal.x - this.#target.x) * ease;
this.#target.y += (goal.y - this.#target.y) * ease;
this.#target.z += (goal.z - this.#target.z) * ease;
diff --git a/packages/create-threenative/templates/snow/src/render/scenery.ts b/packages/create-threenative/templates/snow/src/render/scenery.ts
index 85be4b9685..b14b00b67c 100644
--- a/packages/create-threenative/templates/snow/src/render/scenery.ts
+++ b/packages/create-threenative/templates/snow/src/render/scenery.ts
@@ -9,6 +9,7 @@ import {
Group,
InstancedMesh,
LineSegments,
+ MathUtils,
Mesh,
Object3D,
PlaneGeometry,
@@ -27,16 +28,15 @@ export interface IObstacle {
}
function seededRandom(seed: number): () => number {
- let state = seed;
+ let state = seed >>> 0;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
-
const smoothstep = (edge0: number, edge1: number, value: number) => {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
/** Glade-edge pines by hand, then a ring of forest further out. `[x, z, height]`. */
diff --git a/packages/create-threenative/templates/snow/src/render/touch-controls.ts b/packages/create-threenative/templates/snow/src/render/touch-controls.ts
index c82c6fd30f..9c40138178 100644
--- a/packages/create-threenative/templates/snow/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/snow/src/render/touch-controls.ts
@@ -14,6 +14,9 @@ import { palette } from "./palette.js";
const MOVE_RADIUS = 72;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -101,13 +104,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#moveKnob.material = this.#hasMoveAnchor ? this.#activeMaterial : this.#idleMaterial;
this.#layout(size);
return this.#input;
diff --git a/packages/create-threenative/templates/snow/src/render/weather.ts b/packages/create-threenative/templates/snow/src/render/weather.ts
index 42da6c2e5a..a86920195e 100644
--- a/packages/create-threenative/templates/snow/src/render/weather.ts
+++ b/packages/create-threenative/templates/snow/src/render/weather.ts
@@ -1,7 +1,7 @@
// Generated for you. Snowfall, the blizzard and the powder a boot kicks up. `GPUParticles3D` (the
// scene passes it in) owns
// the buffers, dispatch and lifetime; the flake's shape, colour, size, drift and fall are here.
-import { type Sprite, Vector2 } from "three";
+import { MathUtils, type Sprite, Vector2 } from "three";
import {
Fn,
atan,
@@ -233,8 +233,6 @@ export function createWeather(options: {
let wind = 0.4;
let drifted = { x: 0, z: 0 };
let fallen = 0;
- const damp = (a: number, b: number, rate: number, dt: number) =>
- a + (b - a) * (1 - Math.exp(-rate * Math.max(0, dt)));
return {
bursts: bursts.map((burst) => burst.particles),
burst(at, strength) {
@@ -253,10 +251,10 @@ export function createWeather(options: {
update(dt, centre, pixelScale, settings) {
fall.pixelScale.value = pixelScale;
time += dt;
- storm = damp(storm, settings.blizzard ? 1 : 0, 0.95, dt);
+ storm = MathUtils.damp(storm, settings.blizzard ? 1 : 0, 0.95, dt);
const gust = 1 + 0.22 * Math.sin(time * 1.1) + 0.12 * Math.sin(time * 2.73 + 1.6);
const target = (settings.wind * (1 - storm) + (9.5 + settings.wind * 0.5) * storm) * gust;
- wind = damp(wind, target, 2.3, dt);
+ wind = MathUtils.damp(wind, target, 2.3, dt);
drifted = { x: drifted.x + wind * dt, z: drifted.z + wind * 0.24 * dt };
fallen += settings.fallSpeed * (0.95 + storm * 0.7) * dt;
fall.drift.value.set(drifted.x, drifted.z);
diff --git a/packages/create-threenative/templates/snow/src/terrain.ts b/packages/create-threenative/templates/snow/src/terrain.ts
index e65e3f34bf..f0f346bb87 100644
--- a/packages/create-threenative/templates/snow/src/terrain.ts
+++ b/packages/create-threenative/templates/snow/src/terrain.ts
@@ -1,4 +1,5 @@
import { Heightfield, SnowField } from "@threenative/core/world";
+import { MathUtils } from "three";
/** The playable snowfield is a square this many metres across, centred on the origin. */
export const FIELD_SIZE = 24;
@@ -22,7 +23,7 @@ export const EXPLORER_LAYER = 0b1000;
function smoothstep(edge0: number, edge1: number, value: number): number {
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
}
/** Rolling ground under the snow: gentle swells in the glade, hills beyond it. */
diff --git a/packages/create-threenative/templates/starter/src/entities/Player.ts b/packages/create-threenative/templates/starter/src/entities/Player.ts
index 07d81e4bd7..673ad32ca8 100644
--- a/packages/create-threenative/templates/starter/src/entities/Player.ts
+++ b/packages/create-threenative/templates/starter/src/entities/Player.ts
@@ -1,6 +1,14 @@
import { BlendSpace1D, type ICtx, SkeletalMesh3D } from "@threenative/core";
import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics";
-import { type AnimationClip, Group, type Mesh, type Object3D, Quaternion, Vector3 } from "three";
+import {
+ type AnimationClip,
+ Group,
+ MathUtils,
+ type Mesh,
+ type Object3D,
+ Quaternion,
+ Vector3,
+} from "three";
import { type IStarterConventions, preparePlayerConventions } from "../conventions.js";
import type { ITouchInput } from "../render/touch-controls.js";
import type { GameState } from "../state.js";
@@ -143,7 +151,7 @@ export class Player {
// Touch walks until the stick is fully deflected; no separate run button.
const running = ctx.input.pressed("run") || (touch?.move.length() ?? 0) >= 1;
const speed = running ? RUN_SPEED : WALK_SPEED;
- const ease = 1 - Math.exp(-dt / 0.15);
+ const ease = MathUtils.damp(0, 1, 1 / 0.15, dt);
const moveZ = -move.y;
this.body.velocity.x += (move.x * speed - this.body.velocity.x) * ease;
this.body.velocity.z += (moveZ * speed - this.body.velocity.z) * ease;
diff --git a/packages/create-threenative/templates/starter/src/render/camera.ts b/packages/create-threenative/templates/starter/src/render/camera.ts
index c7fcaf92ba..b17958a991 100644
--- a/packages/create-threenative/templates/starter/src/render/camera.ts
+++ b/packages/create-threenative/templates/starter/src/render/camera.ts
@@ -9,7 +9,7 @@
// Camera framing is one of the loudest things in a screenshot, so it lives
// here in your repo rather than behind a framework option.
import type { PerspectiveCamera, Vector3 } from "three";
-import { Vector3 as Vec3 } from "three";
+import { MathUtils, Vector3 as Vec3 } from "three";
export interface ISpringArmOptions {
/** Where the camera sits relative to its target, before smoothing. */
@@ -102,7 +102,7 @@ export function createSpringArm(
desired.copy(target).add(targetOffset());
// 1 - e^(-dt/damping): the frame-rate-independent form of a lerp. At 30fps
// and 120fps the camera ends up in the same place after the same second.
- camera.position.lerp(desired, 1 - Math.exp(-dt / damping));
+ camera.position.lerp(desired, MathUtils.damp(0, 1, 1 / damping, dt));
clear(target);
camera.lookAt(aim.copy(target).add(lookAhead));
};
diff --git a/packages/create-threenative/templates/starter/src/render/clouds.ts b/packages/create-threenative/templates/starter/src/render/clouds.ts
index 0bb22540cb..1693ce841a 100644
--- a/packages/create-threenative/templates/starter/src/render/clouds.ts
+++ b/packages/create-threenative/templates/starter/src/render/clouds.ts
@@ -6,6 +6,8 @@
// Independent CPU implementation. The material samples four Unreal textures that this repository
// must not carry, so each one is regenerated here from a seeded noise whose per-channel value
// quantiles and correlation lengths match the Unreal texture (measured, see the spec).
+import { MathUtils } from "three";
+
import {
BOTTOM_KM,
CAMERA_KM,
@@ -110,7 +112,7 @@ const PROFILE_COEFFICIENTS = [
const clamp = (v: number): number => Math.max(0, Math.min(1, v));
const smooth = (v: number): number => {
const t = clamp(v);
- return t * t * (3 - 2 * t);
+ return MathUtils.smoothstep(t, 0, 1);
};
const mod = (a: number, n: number): number => ((a % n) + n) % n;
diff --git a/packages/create-threenative/templates/starter/src/render/materials.ts b/packages/create-threenative/templates/starter/src/render/materials.ts
index 44bd289187..c31b239bc1 100644
--- a/packages/create-threenative/templates/starter/src/render/materials.ts
+++ b/packages/create-threenative/templates/starter/src/render/materials.ts
@@ -11,6 +11,7 @@ import {
DoubleSide,
Float32BufferAttribute,
LinearMipmapLinearFilter,
+ MathUtils,
MeshStandardMaterial,
RepeatWrapping,
SRGBColorSpace,
@@ -86,8 +87,8 @@ function grainNormalTexture(size = 128): DataTexture {
const y0 = Math.floor(v);
const fx = u - x0;
const fy = v - y0;
- const sx = fx * fx * (3 - 2 * fx);
- const sy = fy * fy * (3 - 2 * fy);
+ const sx = MathUtils.smoothstep(fx, 0, 1);
+ const sy = MathUtils.smoothstep(fy, 0, 1);
const at = (i: number, j: number): number =>
values[(((j % count) + count) % count) * count + (((i % count) + count) % count)] ?? 0;
const top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * sx;
diff --git a/packages/create-threenative/templates/starter/src/render/touch-controls.ts b/packages/create-threenative/templates/starter/src/render/touch-controls.ts
index 124c217579..54683cd1a5 100644
--- a/packages/create-threenative/templates/starter/src/render/touch-controls.ts
+++ b/packages/create-threenative/templates/starter/src/render/touch-controls.ts
@@ -15,6 +15,9 @@ const MOVE_RADIUS = 72;
const BUTTON_RADIUS = 62;
const EDGE = 36;
+/** The knob's next point: `update` runs every frame, so it uses one instance. */
+const knobPosition = new Vector2();
+
export interface ITouchPointer {
readonly position: Vector2;
}
@@ -113,13 +116,11 @@ export class TouchControls {
}
const center = this.#hasMoveAnchor ? this.#moveAnchor : this.#resting;
- place(
- this.#moveKnob,
- new Vector2(
- center.x + this.#input.move.x * MOVE_RADIUS,
- center.y - this.#input.move.y * MOVE_RADIUS,
- ),
+ knobPosition.set(
+ center.x + this.#input.move.x * MOVE_RADIUS,
+ center.y - this.#input.move.y * MOVE_RADIUS,
);
+ place(this.#moveKnob, knobPosition);
this.#jump.material = jump ? this.#activeMaterial : this.#idleMaterial;
this.#input.jumpPressed = jump && !this.#wasJump;
this.#wasJump = jump;
diff --git a/packages/create-threenative/templates/starter/src/scenes/Play.ts b/packages/create-threenative/templates/starter/src/scenes/Play.ts
index b97ef35df0..92e715fed7 100644
--- a/packages/create-threenative/templates/starter/src/scenes/Play.ts
+++ b/packages/create-threenative/templates/starter/src/scenes/Play.ts
@@ -451,20 +451,7 @@ export class Play extends Scene {
),
);
frameState.respawns = previous.respawns + (respawned ? 1 : 0);
- const current = frameCtx.state.getState();
- const changed =
- frameState.coyoteJumps !== current.coyoteJumps ||
- frameState.flagDisplacement !== current.flagDisplacement ||
- frameState.flagReadbacks !== current.flagReadbacks ||
- frameState.flagSteps !== current.flagSteps ||
- frameState.jumps !== current.jumps ||
- frameState.lives !== current.lives ||
- frameState.odometer !== current.odometer ||
- frameState.peakRise !== current.peakRise ||
- frameState.playerX !== current.playerX ||
- frameState.progress !== current.progress ||
- frameState.respawns !== current.respawns;
- if (changed) frameCtx.state.set(frameState);
+ frameCtx.state.set(frameState);
if (respawned) frameCtx.state.flush();
};
}
diff --git a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts
index 01a1766c7f..e5a93682e3 100644
--- a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts
+++ b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts
@@ -399,11 +399,10 @@ export function reactor(): Group {
export function lcg(seed: number): () => number {
let state = seed >>> 0;
return () => {
- state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ state = (state * 1664525 + 1013904223) >>> 0;
return state / 4294967296;
};
}
-
function scatter(
geometry: BufferGeometry,
material: Material,
diff --git a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts
index 46a91469d1..2e177b88f4 100644
--- a/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts
+++ b/packages/create-threenative/templates/tower-defense/src/scenes/Battle.ts
@@ -68,11 +68,6 @@ const STRIKE_BOUNDS = { x: 19, z: 12 } as const;
const LEFT_SHIFT = "ShiftLeft";
const RIGHT_SHIFT = "ShiftRight";
-function sameState(a: GameState, b: GameState): boolean {
- for (const key of Object.keys(a) as (keyof GameState)[]) if (a[key] !== b[key]) return false;
- return true;
-}
-
export class Battle extends Scene {
#environmentSample: IEnvironmentSample | undefined;
#materialLighting: ReturnType | undefined;
@@ -514,8 +509,8 @@ export class Battle extends Scene {
ghost.visible = true;
};
- // Publish only what changed: the HUD is another process on native, and a frame that changed
- // nothing should cost it nothing.
+ // The store publishes only what changed: the HUD is another process on native, and a frame
+ // that changed nothing costs it nothing.
const next: GameState = { ...INITIAL_STATE };
const publish = (frameCtx: FrameCtx, speed: number): void => {
const previous = frameCtx.state.getState();
@@ -556,7 +551,7 @@ export class Battle extends Scene {
next.towers = towers.size;
next.uiReady = previous.uiReady;
next.wave = waves.wave;
- if (!sameState(next, previous)) frameCtx.state.set(next);
+ frameCtx.state.set(next);
};
this.#materialLighting = ctx.entities.add(
diff --git a/packages/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts
index d9c7402ef4..3bccbee697 100644
--- a/packages/engine-mcp/__tests__/search.spec.ts
+++ b/packages/engine-mcp/__tests__/search.spec.ts
@@ -197,6 +197,23 @@ describe("threenative-engine-mcp", () => {
expect(results.slice(0, 3).map((result) => result.symbol)).toContain("CharacterBody3D");
});
+ it("finds the ctx members an author searches for by name (PRD-872)", () => {
+ const cases = [
+ ["cache generated geometry between page loads", "ctx.assets.memo"],
+ ["boot progress bar", "ctx.startup.progress"],
+ ["run a callback after a delay", "ctx.after"],
+ ["run a callback before the frame renders", "ctx.beforeRender"],
+ ] as const;
+ for (const [situation, symbol] of cases) {
+ expect(
+ searchResults(situation)
+ .slice(0, 3)
+ .map((result) => result.symbol),
+ situation,
+ ).toContain(symbol);
+ }
+ });
+
it("finds measured miss-list vocabulary across the owning packages", () => {
const cases = [
["different props in each area", "createAssetLoader"],
diff --git a/scripts/api-surface.json b/scripts/api-surface.json
index bb75ad7b40..4e9ea51128 100644
--- a/scripts/api-surface.json
+++ b/scripts/api-surface.json
@@ -176,6 +176,7 @@
"@threenative/core#createReplayDriver": "export function createReplayDriver( recording: Recording, target: EventTarget, pointerTarget = target, ) { … }",
"@threenative/core#Daylight": "export class Daylight extends Group implements IComputeDriven { … }",
"@threenative/core#debugFlag": "export function debugFlag(name: string): boolean { … }",
+ "@threenative/core#debugValue": "export function debugValue(name: string): string | undefined { … }",
"@threenative/core#defineGame": "export function defineGame, TPhysics = undefined>( config: IGameConfig, ): IGame { … }",
"@threenative/core#describeSceneShape": "export function describeSceneShape( window: IFrameBudgetWindow, cull: IRenderCameraCullReport | undefined, ): ISceneShape | undefined { … }",
"@threenative/core#describeSceneWarning": "export function describeSceneWarning(warning: ISceneWarning): string { … }",
@@ -314,9 +315,13 @@
"@threenative/core#withVelocityContext": "export function withVelocityContext(node: T, source: Node): T { … }",
"@threenative/core#WorldEnvironment": "export class WorldEnvironment { … }",
"@threenative/core#zenithTransmittance": "export function zenithTransmittance( parameters: IAtmosphereParameters | IResolvedAtmosphereParameters, ): AtmosphereRgb { … }",
+ "src/assets.ts#ctx.assets.memo": "ctx.assets.memo(key: string, build: () => T | Promise): Promise",
"src/game.ts#renderer.matrixWorld": "renderer.matrixWorld?: \"visible\" | \"all\"",
"src/game.ts#renderer.minimumProjectedPixels": "renderer.minimumProjectedPixels?: number | false",
- "src/game.ts#renderer.projection": "renderer.projection?: boolean | { materialChecks?: 'spread' | 'everyFrame' }"
+ "src/game.ts#renderer.projection": "renderer.projection?: boolean | { materialChecks?: 'spread' | 'everyFrame' }",
+ "src/scene.ts#ctx.after": "ctx.after(delay: number, callback: () => void): ScheduleHandle",
+ "src/scene.ts#ctx.beforeRender": "ctx.beforeRender(callback: () => void): () => void",
+ "src/scene.ts#ctx.startup.progress": "ctx.startup.progress: number"
}
},
"@threenative/metahuman": {
diff --git a/scripts/build-capability-manifest.ts b/scripts/build-capability-manifest.ts
index 16f5c3a6a3..1077a29ad4 100644
--- a/scripts/build-capability-manifest.ts
+++ b/scripts/build-capability-manifest.ts
@@ -5,6 +5,7 @@ import ts from "typescript";
import { RELEVANCE_FLOOR, capabilitySituationTokens } from "../packages/engine-mcp/src/index.js";
import { CAPABILITY_PACKAGE_ALLOWLIST, isPublicClassOrFunction } from "./check-capability-docs.js";
+import { CTX_MANIFEST_ENTRIES } from "./ctx-capabilities.js";
import { type INotOwnedCapability, NOT_OWNED_CAPABILITIES } from "./not-owned-capabilities.js";
import {
REALISM_EFFECTS_COVERAGE,
@@ -728,6 +729,7 @@ export function buildCapabilityManifest(
symbol: candidate.symbol,
})),
...REALISM_EFFECTS_MANIFEST_ENTRIES.map((entry) => ({ ...entry, aliases: [] })),
+ ...CTX_MANIFEST_ENTRIES.map((entry) => ({ ...entry, aliases: entry.aliases ?? [] })),
...RENDER_CHAIN_MANIFEST_ENTRIES.map((entry) => ({
...entry,
aliases: entry.aliases ?? [],
diff --git a/scripts/ctx-capabilities.ts b/scripts/ctx-capabilities.ts
new file mode 100644
index 0000000000..543e363365
--- /dev/null
+++ b/scripts/ctx-capabilities.ts
@@ -0,0 +1,121 @@
+import type { ICapabilityManifestEntry } from "./build-capability-manifest.js";
+
+/**
+ * The `ctx` members that exist only as interface properties, so nothing exports them and the
+ * manifest cannot find them by walking exports.
+ *
+ * This is the same gap the render-chain stages had: `ctx.assets.memo` shipped and worked, and
+ * `engine_search_capabilities("cache generated geometry between page loads")` returned nothing,
+ * so a game rebuilt a nine-second stage on every load. The manifest is the thing an agent searches
+ * first; a member absent from it does not exist. Each row therefore carries the words an author
+ * types before they know the name, plus the constraint that actually bites.
+ *
+ * The JSDoc on the interface member stays the source of the behaviour; this file is the index.
+ */
+function member(entry: {
+ readonly symbol: string;
+ readonly signature: string;
+ readonly summary: string;
+ readonly situations: readonly string[];
+ readonly constraints: readonly string[];
+ readonly example: string;
+ readonly overrides?: readonly string[];
+ readonly importPath?: string;
+}): ICapabilityManifestEntry {
+ return {
+ symbol: entry.symbol,
+ package: "@threenative/core",
+ importPath: entry.importPath ?? "src/scene.ts",
+ kind: "function",
+ signature: entry.signature,
+ summary: entry.summary,
+ situations: entry.situations,
+ aliases: [],
+ example: entry.example,
+ constraints: entry.constraints,
+ overrides: entry.overrides ?? [],
+ supersedes: [],
+ };
+}
+
+export const CTX_MANIFEST_ENTRIES: readonly ICapabilityManifestEntry[] = [
+ member({
+ symbol: "ctx.assets.memo",
+ importPath: "src/assets.ts",
+ signature: "ctx.assets.memo(key: string, build: () => T | Promise): Promise",
+ summary:
+ "Returns what `build` returned the last time this build of the game ran, read back from disk, and calls `build` only when nothing is stored. Use it for seeded work that is the same every run: placement lists, painted sky pixels, terrain heights, a generated stage.",
+ situations: [
+ "cache generated geometry between page loads",
+ "stop rebuilding the same generated stage on every page load",
+ "cache painted sky pixels or baked terrain heights between runs",
+ "keep seeded placement work the same every run without recomputing it",
+ "make a slow boot fast without shipping a baked asset",
+ ],
+ constraints: [
+ "The value is named typed arrays; a map of names to typed arrays is what is stored.",
+ "On the dev server and on a native host `build` runs every time, and `TN_ASSET_MEMO` reports why: the cache is a build artifact, not a substitute for authoring.",
+ "A new build of the game regenerates: the key alone does not pin a value across a rebuild.",
+ "A game that drives a loader outside `defineGame` may call it whenever it likes; the reveal is not waited on.",
+ ],
+ example: 'const { pixels } = await ctx.assets.memo("sky", () => ({ pixels: paintSky() }));',
+ overrides: ["TN_ASSET_MEMO reports why a run rebuilt instead of reading the store"],
+ }),
+ member({
+ symbol: "ctx.startup.progress",
+ signature: "ctx.startup.progress: number",
+ summary:
+ "How much of the start scene has loaded and presented, 0 to 1, for a loading cover's bar. It never falls: a loader whose pending work grows keeps the number it reached rather than jumping backwards.",
+ situations: [
+ "boot progress bar",
+ "show how much of the start scene has loaded",
+ "loading screen percentage",
+ "a progress bar that never goes backwards",
+ "report startup progress while assets stream in",
+ ],
+ constraints: [
+ "Never decreases, even when the loader is asked for more work mid-boot.",
+ "Reaches 1 only after the first world render has been presented, not when the last byte arrived.",
+ "A loading view the engine drew for you already reads it; this is for a cover the game draws itself.",
+ ],
+ example:
+ "const width = `${Math.round(ctx.startup.progress * 100)}%`; // a cover the game draws on ctx.canvasLayer",
+ }),
+ member({
+ symbol: "ctx.after",
+ signature: "ctx.after(delay: number, callback: () => void): ScheduleHandle",
+ summary:
+ "Runs `callback` once, `delay` seconds of game time later, and returns a handle that cancels it. Game time stops while the game is paused, so a delayed beat does not fire behind a paused frame.",
+ situations: [
+ "run a callback after a delay",
+ "schedule a one-shot timer that pauses with the game",
+ "do something a moment later without setTimeout",
+ "cancel a pending delayed action when a scene exits",
+ ],
+ constraints: [
+ "The delay is game seconds: a paused game does not age it, and a slow frame does not skip it.",
+ "`setTimeout` keeps running while the game is paused and after the scene is gone; this does not.",
+ "Call the returned handle to cancel: a pending callback holds its scene's closure alive.",
+ ],
+ example: "const spawn = ctx.after(0.4, () => ctx.add(makeWave()));\nspawn(); // cancels",
+ }),
+ member({
+ symbol: "ctx.beforeRender",
+ signature: "ctx.beforeRender(callback: () => void): () => void",
+ summary:
+ "Registers work that runs once per actual world render, after the frame's last fixed update and before the projection reconciles and the renderer draws. The last place to change a transform, a uniform or a camera before the frame is presented.",
+ situations: [
+ "run a callback just before the frame renders",
+ "update a camera or a uniform after physics and before the draw",
+ "change a transform in the same frame it is seen",
+ "run per-render work that must not run on a loader-only frame",
+ ],
+ constraints: [
+ "A held, loader-only frame has no world draw and dispatches nothing.",
+ "Runs after every fixed update in the frame, so a value it writes is the one drawn.",
+ "Returns an unregister function; call it when the object that needed it is gone.",
+ ],
+ example:
+ "const stop = ctx.beforeRender(() => (material.uniforms.time.value = clock.elapsed));\n// later: stop();",
+ }),
+];