feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85
feat(playset): scene3d surface + GameBlocks port + native wgpu core — rally renders#85doodlewind wants to merge 23 commits into
Conversation
…, foundation modules - scene3d: closed write-only presentation vocabulary (ops.ts), retained guest mirror with batched pose flush (client.ts), renderless sim host with canonical serialization for goldens (sim.ts), <Viewport3D> - playset/math: clean-room three@0.161-compatible math subset - modules/math: WorldBasis, Vector3Utils, Scalar/Random/TimeUtils ports (Clock defaults to the virtual clock, never Date.now) - modules/physics: deterministic CollisionWorld (capsule resolve, raycasts, walkable tops) — the injected-Rapier replacement seam - loop.ts: fixed 1/60 game loop, hz-invariant via ticksPerFrame catch-up Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
actor-motion (18), behavior (6), camera (5), gameplay (8), math (5), user-interface (8), world (24) — kebab-case files, PascalCase exports, verbatim semantics with per-file deviation notes and MIT attribution. Reengineered seams (documented in headers): - KinematicBatchResolver + DynamicCarBatchResolver run on the deterministic CollisionWorld (Rapier replaced; dynamic car is a kinematic raycast-vehicle approximation pending the native block) - AimResolver picks via CollisionWorld.raycast + Camera3D.rayFromNdc - visual factories/effects build SceneNode trees; JetFlame shader -> additive cones; tire marks/tracers -> beam pools; shadows -> blob-shadow.ts decals - DOM/Canvas2D HUD -> Solid components (radar/minimap as View trees) 243 new tests (344 total in playset/), all green; repo test script now runs playset/test/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- demos/rally: checkpoint-racing demo composing track environment, arcade car + AI rival (waypoint driver), kinematic resolver, chase camera, Viewport3D + HUD (lap/standings/minimap) - playset/test/rally-sim.test.ts: host-sim goldens — byte-identical framebuffer + scene3d serialization across boots, 30/60 Hz exact subsampling relation, graceful no-s3 degradation - summary.md (77-entry catalog), README.md, SKILL.md with the Three.js -> playset porting mapping table Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kdrop Core emits sceneQuad(xy, wh, handle) at a node's world AABB before its background when the host has bound a scene3d scene (host-written prop, never app code). All DrawList consumers skip it gracefully: wasm raster and PSP GE draw nothing (graceful absence), pocket-ui-wgpu skips (the 3D-capable host composites from the same DrawList). No golden drift — the prop is only ever set by hosts with a scene3d core. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…top host New pocket3d/crates/pocket-scene3d: retained store implementing the scene3d contract (sim.ts semantics: id lifecycle, silent dead-handle ops, pool replace+clamp, heightfield tessellation with smooth normals), rquickjs mount as globalThis.s3, and a wgpu renderer — per-scene pass into the sceneQuad rect: gradient sky, per-vertex sun+hemisphere lit meshes with linear fog (fixed-function honest), back-to-front transparent/additive, camera-facing sprite pools + view-aligned beams. pocket-ui-wgpu grows scene_quads() (DrawList walker) + UiSurface set_prop passthrough. uihost mounts s3 before eval, bridges bindViewport -> PROP.scene3d, composites scenes under the ui layer (LoadOp::Load), adds --hold for scripted screenshot input. rally verified visually at 480x272 (terrain/fog/cars/gates/HUD); hero-main regression unchanged; 12 crate tests + full bun run test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he PSP host pocket-scene3d store goes no_std+alloc behind a feature gate (desktop keeps mount+wgpu under default 'std'; BTreeMap swap, fmath shim) so both hosts share one semantics. native/ gains scene3d.rs (globalThis.s3 over the QuickJS C API, bindViewport -> PROP.scene3d) and ge3d.rs (SCENE_QUAD compositor: rect-scoped viewport/scissor, depth-only clear, gouraud sky strip, GE hardware sun + hemisphere-average ambient, sceGuFog, additive pools, 2D-clean exit state). Always-on perf probe logs avg_js_us every 300 frames for the 333MHz hardware spike (bun run hw rally -r). PPSSPP-verified: rally f10/f240 at RMSE 5.4%/5.3% vs the desktop wgpu references; all 23 PSP ui goldens byte-exact; tape:check 180 frames; desktop uihost screenshot byte-identical after the store rework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three ports following the rally pipeline (fixed-step sim, seeded PRNG, Viewport3D + HUD, sim-host goldens, uihost screenshot verification): - runner: 3-lane endless runner (jump/slide, coins/boost, difficulty ramp, snow sprite pool) — clean-room glue, numbers from the original - dogfight: the flight+combat+UI showcase — 17 playset modules as-is, FlightHud + HeadingRelativeRadar in action, wave AI, missile lock, terrain crash referee - snake: 16x16 grid arena vs A*/flood-fill AI rival, cadence ramp 16 new sim tests (byte-identical two-boot traces, 30/60 Hz subsampling, no-s3 degradation, gameplay probes); all bundles in the test chain. Module adaptations: WeaponEffectsSystem gains tracerWidth/particleSize options; FlightHud gets font classes + 480x272 layout fixes (unclassed Text renders nothing on native hosts — systemic gap noted for core). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PSPLINK boots the console at 222/111 MHz; the host never set the retail clock, so every hardware run paid a 1.5x CPU / 1.5x bus tax. run() now pins 333/333/166 and the perf line prints a cpu_mhz readback. The always-on probe also appends to host0:/PocketJS-perf.txt (PSPLINK's tty only reaches a shell connected at module start and drops on every reconnect — the file lands in the served target dir on the host), and when the app publishes cumulative counters on globalThis.__jsPerf it emits a [pocketjs jsperf] split line. s3 gains __hwNow (µs clock, debug-only, not part of the Scene3dOps contract) to feed those counters. Measured on device (rally): QuickJS ≈ 1.7µs per interpreter op at 333 MHz (soft-float f64) ≈ 8k-op frame budget; GE waits 29µs — the 3D compositor is never the bottleneck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly HUD throttle
Measured on real PSP hardware (333 MHz, QuickJS -O2), rally per frame:
step 490ms -> 268ms CollisionWorld planar grid broadphase; gathers
sort by handle so resolution keeps exact
insertion-order semantics (goldens byte-equal)
flush 128ms -> 46ms Scene3D.markStatic(root?): settled scenery is
skipped by the per-frame pose differ
hud 69ms -> 12ms rally refreshes the HUD on the virtual 0.1s
grid (sim steps, not render frames — keeps the
30/60Hz subsampling theorem for HUD pixels)
total 689ms -> 328ms
All 365 tests + PPSSPP e2e stay byte-exact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware perf spike — answered on a real PSP (2026-07-20)The standing question ("is QuickJS per-frame 3D sim fast enough at 333 MHz?") now has a measured answer: no, by ~20×, and it's interpreter physics, not a fixable bug. Two root fixes found on the way
Calibrated interpreter cost (on device, 333 MHz, -O2)
Frame budget 16,667µs ≈ ~8k interpreter ops. Rally's step runs ~250k. rally, per frame (steady state, 300-frame windows)
GE wait: 29µs — the scene3d GE compositor is never the bottleneck. Rust tick+draw+enqueue ≈ 18ms. snake (unoptimized): 54ms steady. hero (ui-only) was always 60 fps. Consequence for wave 2+60 fps sims on PSP need the native hot-path blocks already on the backlog (Rust behind the same batch-resolver seams) or sims sized under ~8k ops/frame. Vita/desktop are unaffected (hard-float). All 365 tests + PPSSPP e2e stay byte-exact. 🤖 Generated with Claude Code |
…real PSP
[[quickjs-psp-perf-wall]] measured the ceiling: QuickJS on this CPU costs
~1.7µs per interpreter op, so a 60 Hz frame affords ~8k ops and the TS rally
step needed ~160k. This lands the hot GameBlocks blocks as native f32 Rust and
then chases what that exposed.
pocket3d/crates/pocket-playset math / collision / terrain / vehicle /
resolver / behavior / world, no_std+alloc for PSP and std for desktop —
one crate, two hosts, same semantics (the pocket-scene3d layout).
playset/sim/ops.ts the `ps` contract, mounted by
native/src/playset.rs (QuickJS C API) and pocket-playset/src/mount.rs
(rquickjs). The sim OWNS scene3d node handles and writes poses straight
into the shared store, so the per-frame pose batch never becomes JS: the
guest's whole turn is one `step` call and one 15-float `readHud`.
The TS modules REMAIN the reference implementation. A host without `ps` runs
them unchanged — the same graceful absence `s3` follows — and every byte-exact
golden still pins that path. f32 vs f64 makes the two trajectory-equivalent,
not bit-equivalent, so parity is asserted as bounded divergence instead
(tests/parity.rs, generated by playset/test/gen-parity-fixture.ts): over 600
frames the native car stays within 0.05u of the reference racing line, and the
bounds are mutation-tested (a steer-sign flip misses by 300x).
MEASURED per frame on a real PSP (333 MHz, release), rally:
689 ms -> 13.1 ms average, 91.5% of frames inside the 16.67 ms budget
sim step 268 ms -> 1.7 ms the port itself
flush 128 ms -> 0.6 ms Scene3D.markStatic + a dynamic-only walk list;
the native path hands the car nodes over to
the sim, so the guest walk empties completely
HUD 69 ms -> 2.0 ms one signal per field instead of one object
signal whose fresh identity re-ran everything
ge::render 17.6 ms -> 4.3 ms frustum culling (707 draws -> ~65, 23.6k tris
-> ~6.4k), retained scratch buffers, and a
terrain split into 6x6 cullable patches
Two findings worth keeping:
- Physics must sample the DRAWN terrain. The procedural samplers hash through
`fract(sin(...))`, which is a different function in f32 than in f64 (1.94 vs
2.59 at the same point), so a native core re-deriving heights puts the car on
a surface that is not on screen. `createTerrainMesh` now hands its
tessellated grid to `ps.terrainHeightfield` — physics and pixels read the
same data, and a bilinear lookup is cheaper than the procedural path.
- CameraRigCfg offsets are from_basis-encoded; a raw Vec3 puts the chase
camera in front of the car.
Remaining: ~8.5% of frames miss the budget, all of them minimap marker
refreshes. Rebuilding and diffing a twelve-key style object per marker costs
27 ms on this interpreter — far more than its op count explains, and not GC
(raising the collector threshold changed nothing). Markers now refresh at 5 Hz
to halve the hitch rate; making the update itself cheap is the follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rally on a real PSP: 689 ms → 13.1 ms per frameFollow-up to the perf spike. The native sim cores landed, and then chased everything they exposed. Commit 0e70e61. Per frame, measured on hardware (333 MHz, release)
91.5% of frames land inside the 16.67 ms budget. What did it
The TS path is untouchedThe playset modules remain the reference implementation; a host without Two findings worth stealing
Known remainderThe last 8.5% of over-budget frames are all minimap marker refreshes: rebuilding and diffing a twelve-key style object per marker costs 27 ms on this interpreter, far more than its op count explains. It is not GC — raising the collector threshold changed nothing, measured and reverted. Markers are gated to 5 Hz to halve the hitch rate; making the update itself cheap is the follow-up. 365 TS tests + 77 Rust tests + PPSSPP e2e all green. 🤖 Generated with Claude Code |
# Conflicts: # native/src/lib.rs # native/src/main.rs # package.json # pocket3d/crates/pocket-ui-wgpu/src/render.rs # tsconfig.json
Merged
|
| file | resolution |
|---|---|
native/src/lib.rs |
union — scene3d + playset alongside main's stats/svc/vid |
native/src/main.rs |
union of the pocketjs_psp::{...} import |
package.json |
main's test chain is a superset of the shared base; kept it and appended the playset segment (parity --check + the four demo builds + playset/test/) |
pocket3d/.../render.rs |
took main's DPI-scaled xy/wh closures; the module-level decode_xy/decode_wh stay unscaled because scene_quads reports logical ui px and its caller owns the scaling |
tsconfig.json |
dropped the retired @pocketjs/aot/* paths (main removed aot/), kept @pocketjs/playset/* and added @pocketjs/playset/sim |
Also had to rebuild host-web/pocketjs.wasm — main added core ops (ui_set_active) and the checked-in binary was stale, which failed the sim suites until regenerated.
Verification after the merge: 654 TS tests across the full merged chain, 77 Rust tests (incl. the rally trajectory-parity fixture, still up to date), cargo check --workspace clean, and the PSP EBOOT builds against main's new pins.
⚠️ The QuickJS -O2 fix is no longer in the build
main repinned the PSP toolchain from local paths to git revs:
libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb..." }I read that rev's build.rs — it still has .opt_level(0). So the interpreter is back to the unoptimized build the perf spike found had been shipping since a 2021 bring-up experiment. That was worth roughly a 2x on JS time, which is a large share of how rally got to 13.1 ms/frame.
Nothing in this PR can fix that on its own. It needs, in order:
- perf(libquickjs-sys): compile QuickJS at -O2 (was -O0 since 2021 bring-up) quickjs-rs#6 (
-O2+ the GC bindings) merged — it is still a draft I opened; native/Cargo.tomlrepinned to the resulting rev.
I did not repin to my unmerged branch's sha: it happens to be fetchable through the fork network, but pinning a shipped build to an unmerged branch is a landmine, and merging a PR in another repo is not mine to decide.
I could not re-measure on hardware — the PSPLINK link wedged partway through (pspsh stopped responding; it needs a physical reset). The regression above is established from the pinned source, not inferred from a measurement.
🤖 Generated with Claude Code
The QuickJS -O2 change (quickjs-rs ba5bdd0, repinned here) is a no-op on this
target and I was wrong to claim otherwise. scripts/psp.ts sets
CRATE_CC_NO_DEFAULTS=1, and cc-rs guards its whole default-flag block on that:
let no_defaults = self.no_default_flags || env("CRATE_CC_NO_DEFAULTS");
if !no_defaults { self.add_default_flags(&mut cmd, &target, &opt_level)?; }
`add_default_flags` is the only place cc emits -O, so `.opt_level(2)` in a
build script never reaches the command line here. TARGET_CFLAGS carried no -O
either, which means clang compiled every C dependency at its -O0 default —
QuickJS included, whose interpreter dispatch loop is the hottest code on the
device.
Putting -O2 in TARGET_CFLAGS is what actually applies it, in psp.ts and
gu-demo.ts (both set the same flag). Evidence it took effect: libquickjs.a
goes 1,779,170 -> 1,478,486 bytes, where switching the build script's
opt_level between 0 and 2 had left it byte-identical in size.
The repin to the -O2 rev stays: it is still the right source of truth, and it
carries the JS_SetGCThreshold/JS_RunGC bindings.
PPSSPP e2e byte-exact and the full suite green with the optimized build, so
the faster interpreter is observably the same interpreter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/psp-toolchain.test.ts guards that every manifest and lockfile carries the one revision from cli/psp-toolchain.json — it caught that the previous commit had moved only native/Cargo.toml. Moves the canonical manifest, native-vita (same pin, unaffected by the -O0 bug: it does not set CRATE_CC_NO_DEFAULTS, so cc emits the profile's -O there) and both lockfiles. Also corrects the note I left on the pin. MEASURED on real hardware with -O2 genuinely applied, rally's guest turn goes 7,119µs -> 3,972µs per frame (-44%) and the critical path 14.8ms -> 13.2ms; the frame is now GE-bound. My earlier "worth roughly 2x" was an inference from a build where the flag never reached the compiler, and it should not have been stated as measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repinned to the -O2 quickjs-rs — and found that
|
pinned -O2 (inert) |
-O2 in TARGET_CFLAGS |
|
|---|---|---|
| guest turn | 7,119 µs | 3,972 µs (−44%) |
| frame work | 13,058 µs | 9,900 µs |
| critical path | 14.8 ms | 13.2 ms |
| over-budget frames | 25 / 300 | 25 / 300 |
The frame is now GE-bound: the display list runs ~8.8 ms and the CPU reaches the sync well before it finishes, so avg_gu_wait_us rose (1.8 → 3.3 ms) even though total work fell. The over-budget count is unchanged because those frames are still the minimap marker refreshes, which are JS-side spikes rather than steady-state cost.
Correctness with the genuinely optimized interpreter: PPSSPP e2e byte-exact, full suite green.
native-vita is unaffected by the -O0 bug — it does not set CRATE_CC_NO_DEFAULTS, so cc emits the profile's optimization level there. It moves to the same rev only to satisfy the one-revision rule.
🤖 Generated with Claude Code
…om the store Reported from hardware: the front wheels stopped visibly swinging when the car turns. The steering rotation was still being written — what was wrong is WHERE. scene3d's `write_poses` always writes position, so rotating a wheel means rewriting its parent-local translation too, and the sim used to snapshot those translations out of the store on its first step. But the game loop steps before it renders, and `Scene3D.flush()` — which is what puts the freshly built visual into the store — runs in render. On frame 0 the sim therefore read identity poses and pinned every wheel to the chassis origin. A car visual carries the offset on the PIVOT (the spin node sits at the pivot's origin), so all four wheels collapsed into one point inside the body: no wheels, and no swing. `carBindVisual` now takes the offsets as data (wheel translations then pivot translations), which is where they were always known — the guest had just set them. No ordering dependency left to get wrong. tests/wheel_offsets.rs covers both halves: offsets survive a step that runs before any flush, and holding left turns the front pivots while the rear ones stay put. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tch it MEASURED on a real PSP: with frustum culling landed the frame is GE-bound, and rally's submitted draw count swings 6-7x with camera direction alone — 72 draws parked facing open ground, 380-470 looking down the circuit. That swing is what a player feels as "it stutters when I chase the other car": chasing means pointing the camera down the longest run of fence, and that fence is ~270 posts plus ~270 rails, each its own draw with its own matrix and material state. `Scene3D.freeze(root?)` is the declaration that lets the host merge them: a promise that these world transforms never change again, so they can be baked into shared geometry. Deliberately STRONGER than `markStatic`, which only says the guest stops diffing a node — a sim-driven car is static in that sense and must never be frozen. The part that matters for scale: the ported environment factories freeze their OWN scenery, so a game composed from NaturalEnvironment / RaceTrackEnvironment inherits the batching without a line of per-game tuning. Scenery knows it is scenery; a Three.js port should not have to learn that this mechanism exists. SKILL.md now states that as the porting contract. This lands the contract, the client and the declarations. The store-side merge and the renderer wiring are in flight separately; `freeze` is optional on the ops surface, so until they land it is inert. Also fixes two typecheck breaks of mine that `bun test` does not catch: VecLike's optional components in the collider sink, and MockHost's new `target` field in the playset UI test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The contract and the guest side landed without an op dispatch, so freeze was inert on every host. Adds it to the PSP QuickJS mount and the desktop rquickjs mount, both reading the node ids as one batched Int32Array — a 550-node environment declares itself in one op rather than 550, which matters when a crossing costs ~0.5ms on the device. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the loop on the report that rally stutters when you chase the other car. It is not the collision: chasing points the camera down the longest run of fence, and the frame is GE-bound, so the submitted draw count is what moves. Measured on the desktop host at real chase-camera positions, mid-track: before batching 380-472 draws + per-cell merge of frozen scenery 209-277 + geometry cache quantization 161-221 + geometry-agnostic grouping 131-182 Pixels are IDENTICAL — a rally A/B with freeze ablated differs in 0 of 130,560 pixels — and `bun run e2e` stays byte-exact. Two of those three steps were fixes to my own design, and both are the kind a port should never have to think about: - The geometry cache keyed on raw floats, so a barrier rail whose length came out of `hypot` over interpolated positions differed in its 15th digit from the identical rail beside it and became its own mesh. ~270 rails, ~270 unshared geometries, nothing to merge. Dimensions now quantize to 0.01 mm before they become a key, and the quantized value is what gets built so the cache never lies about what it returned. - `BatchKey` included the geometry id. Merging is a vertex-level operation — once a member's world transform is baked in, which mesh it came from stops mattering and only the draw state has to match. Keying on it meant props with per-instance randomized dimensions (GameBlocks scatters trees, rocks and grass exactly that way) could never share a batch with anything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry port MEASURED on a real PSP: the obvious HUD wiring — one signal holding a fresh snapshot object, written every frame — cost rally 12,973µs/frame amortised, 78ms per refresh, more than the physics and the scene flush put together. A fresh object is never `===` the old one, so every consumer re-runs whether or not its own value moved, on a HUD where in steady state a speed readout and two map markers are all that change. Rather than hand-fix each demo, `playset/hud.ts` removes the trap: same "snapshot in, accessors out" ergonomics, one signal per field so Solid's identity check stops an update at the source, refreshed on a fixed sim-step interval instead of every frame. The interval is anchored to SIM STEPS, not render frames, and that is load-bearing: counting frames would make the refresh instants depend on simulationHz and break the subsampling relation the deterministic goldens rely on — the same virtual second has to produce the same pixels at 30 Hz and 60 Hz. snake, runner and dogfight all carried the trap and are converted. snake and runner also gate on `status` so the GAME OVER card never waits for the 10 Hz tick; dogfight's `hudSource` now reads each field through its own accessor, so a changing airspeed wakes the airspeed tape and nothing else. Adds scripts/hw-bench.ts: build each demo, run it on the connected PSP, collect the probe's steady-state window, and print a per-demo verdict against the 16,667µs budget. It restarts usbhostfs_pc when a `reset` leaves the link half open, which is otherwise the thing that ends an unattended sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ally's
dogfight's ground was 64,800 triangles in ONE draw whose bounds span the whole
14.5 km map, so no frustum test could ever reject it. Calibrated against rally
on real hardware (~730 triangles/ms through the GE) that single draw is ~89 ms
a frame on its own — more than five frame budgets, whatever the sim costs.
tiles: 16 26,982 submitted (32 only reaches 26,454: a plane looks ACROSS
its terrain, so most of it genuinely is in frustum)
zfar 4200 was 18,000. Fog saturates at 3,900 and its colour IS the sky
colour, so everything past it cost GE time to draw the sky.
Also pins rally's split at the measured 6 with the numbers next to it, and
withdraws an automatic-split rule I briefly added: choosing patches from the
mesh's triangle count made rally WORSE than its hand-tuned value (5,083 ->
8,485 submitted triangles), because adjacent patches duplicate a row of quads
and on a small mostly-visible terrain that duplication outweighs what culling
saves. What actually decides the right split is the fraction of the terrain the
camera can see — which needs the far plane, so the factory cannot know it. The
doc comment now says that, with the measurements, instead of a rule I cannot
justify.
Triangle counts are now in the renderer's debug line: on a GE-bound frame the
vertex count is what costs, and one unculled mesh can outweigh a hundred draws.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The native sim registry held one hard-coded rally World. To port more games without a bespoke op surface per game, `pocket_playset::GameWorld` is now an enum over kinds (Rally, Snake), and step / read_hud / destroy are generic over it. Adding a game is a new variant + a module + a handful of assembly ops; the framework — the store, batching, freeze, pose writing, the registry, the dual-path pattern, the HUD helper — is shared. Assembly ops ARE per game because assembly is per game (a car and a snake grid share no vocabulary), but nothing after boot is. Rally is untouched behaviourally: its ops now reach the inner world through `Sim::rally(id)` and the mounts forward the raw button mask to a generic `step` (GameWorld decodes it — a car reads steer/throttle, a snake the d-pad). `readHud` is per-game width now (rally 15 floats, snake 5). This lands the seam, the snake contract (ops.ts snake* ops, both mounts, the guest dual path in demos/snake/game.ts) and a compiling snake stub. The snake sim logic is in flight separately; until it lands the native path is inert and the TS path — still the reference the byte-exact goldens pin — runs unchanged (snake-sim 5/5 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
snake's rival decides once per grid tick by running, for each of three candidate directions, a flood fill and two searches over the 16×16 board. In TS with string-keyed Sets that tick frame spiked to ~750ms and the demo averaged 45ms/frame on real hardware — a slideshow. The board is integers; in Rust the search is a BFS over a 256-cell array reusing boot-allocated scratch (zero per-tick allocation, no string keys) and costs microseconds. EXACT, not approximate. snake is integer logic, so the native GameWorld matches the TS reference cell-for-cell, tick-for-tick — the parity test replays a button tape across 59 ticks (7 seeded apple picks, a wall death, a restart) and asserts equal apple cells, scores, lengths and heads, not a bound. The two places state could diverge are handled exactly: the Mulberry32 item PRNG evolves with bit-identical wrapping arithmetic and the free-cell index is computed as `(r*n) >> 32` in integer math (no f32/f64 rounding in the pick), and the rival's score argmax is over multiples of 0.5 that are bit-exact in both f32 and f64. Desktop A/B: native vs TS snake is 0 of 130,560 pixels different at frame 240. Second native core behind the GameWorld enum, and the pattern held: a module, an enum variant, six thin assembly ops, a guest dual path. The TS path stays the reference (snake-sim 5/5, all goldens green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g the head Reported on hardware: the snake's body blocks stopped moving with the head. The native path marked the snake roots + apple static (correct — the sim writes their poses, the guest must not diff them) but then called blanket `scene.freeze()`, which froze the WHOLE scene, segments included. Freeze is the stronger promise that a transform is final, so the host baked each segment into merged geometry the moment it became visible — and from then on the sim's per-tick `write_poses` to that node drew nothing, because the batch drew the baked copy and `node_batched` skipped the node's own draw. The head appeared to walk away while the body stuck where it was last re-batched. This is the exact rule rally already follows for its cars (markStatic, never freeze). Snake is JS-bound, not draw-bound, so batching its handful of board nodes bought nothing anyway; the fix is one `scene.markStatic()` over the whole scene and no freeze. Only surfaced intermittently on desktop because a growth toggles visibility, which invalidates and rebuilds the batch at the new position — so between growths it was stale, and the timing hid it. Native-vs-TS is still 0/130,560 pixels at frame 240; all goldens green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Playset — Three.js mini-games (GameBlocks-class) running on Pocket, end to end: framework, native rendering on desktop wgpu AND PSP GE, and the first four games.
Same bundle on the PSP host under PPSSPP: RMSE 5.4% vs the desktop frame. Wave-1 ports: runner · dogfight · snake
Layers
scene3dsurface (playset/scene3d/) — closed write-only 3D presentation vocabulary (the 3D sibling ofui): batchedwritePoses, primitives/heightfield/mesh, vertex-lit fixed-function-honest materials, sun/hemisphere/fog/sky, sprite & beam pools,bindViewportper the<Video>precedent. TS client +<Viewport3D>+ renderless sim host for goldens.playset/modules/— all 74 GameBlocks modules (MIT, attributed) + 3 natives (deterministicCollisionWorldreplacing Rapier, blob-shadow, color-utils). Copy-into-project source perSKILL.md(with the Three.js → playset mapping table).PROP.scene3d+DRAW_OP.sceneQuadthrough spec/core (all four DrawList consumers; graceful absence, zero golden drift). Desktop:pocket3d/crates/pocket-scene3d(retained store + rquickjss3mount + wgpu vertex-lit renderer), composited inuihost. PSP: the store goes no_std behind a feature gate (one semantics, two hosts — the pocket3d-bsp pattern) withnative/src/{scene3d,ge3d}.rs: QuickJS C-API mount, GE hardware sun + hemisphere-average ambient,sceGuFog, depth-only clear, additive pools, 2D-clean exit. Always-on perf probe (avg_js_usper 300 frames) answers the 333 MHz QuickJS question on hardware:bun run hw rally -r.demos/rally(original composition) + wave-1 portsdemos/runner,demos/dogfight(17 modules as-is; FlightHud + radar in action),demos/snake(A*/flood-fill AI rival). Each: fixed-step deterministic sim, two-boot byte-identical goldens, exact 30↔60 Hz subsampling, no-s3 degradation, uihost screenshots in-repo.Validation
bun run testbuilds all five game bundles);tsc --noEmitclean; 12pocket-scene3dcargo tests; no_std build clean; clippy cleantape:check180 frames, rally renders at RMSE 5.4%/5.3% vs desktop refshero-mainscreenshot: pixel-identical (2D path inert)Follow-ups
GameInput, flight-pursuit behavior module, per-target hit radii on projectiles, default font slot for unclassedText(systemic), point-light verb,frameBoundscamera solver, BoardEnvironment rim option, exp fogpocket portfunnel skillbun run hw rally -ron the real PSP (PSPLINK console prints the probe)🤖 Generated with Claude Code