Friction triage — the four js13k recreations (2026-07)

Every FRICTION.md entry from the four hayao@0.3.0 recreations (squad-13, ninja-cat / Triska, ninja-vs-evilcorp, path-to-glory), triaged against repo HEAD. Verdicts:

Cross-cutting themes (what actually got built)

  1. Screen-space HUD layer (node.screenSpace = true + render layer) — the single most-repeated pain across three games (ninja-cat 4.2/4.4, nve 3.2/7.1/10.1, ptg 4/11).
  2. Pause + time scale (world.paused, world.timeScale, node.pauseMode) — squad 14, ptg 18/28.
  3. Deterministic coroutines (Coroutines: generator-based sleep/waitFor/race/all, many concurrent runners) — ptg 6/17/27.
  4. Shape vocabulary (ellipse, arc/sector commands, lineDash paint) — ninja-cat 1.1–1.3, ptg 2.
  5. Renderer robustness (skip + warn-once on non-finite/negative primitives instead of killing the loop) — ptg 34.
  6. Hidden-tab ticker + handle.tick() (built-in document.hidden setTimeout fallback; one-call sample+step+render for harnesses) — squad 1, nve 1.1, ptg 9/32/33.
  7. Typed state (World<TState>, defineGame<TState>, ctx.state on WorldContext) — ninja-cat 3.2, nve 4.1, ptg 1/13/26.
  8. Tree inspection (world.walk, world.nodeCount, world.debugTree(), public flushFree()) — squad 3/7/10.
  9. Input reach (right/middle pointer buttons, unknown-action warn-once) — ptg 3, ninja-cat 7.0.
  10. Character kit (VerletChain rope/streamer node, Node.pivot, Node.x/y) — ninja-cat 10.2/10.5, ptg 22.
  11. ZzFX bridge (specFromZzfx() converter; public audio.ctx/sfxBus/musicBus) — ninja-cat 2.1–2.7, ptg 12/16.
  12. Determinism guard (opt-in dev patch of Math.random/Date.now during step(), warn-once) — ninja-cat 6.1, nve 2.1.

squad-13

#EntryVerdictAction
1rAF dead in hidden iframesCODEBuilt-in ticker fallback in runBrowser: when document.hidden, pump via per-callback setTimeout(16); native rAF when visible. No more per-game monkey-patching (see ptg 33 for why per-game patches go wrong).
2advance() silently caps at 250 msFIXED@HEAD + CODECap is documented and configurable (clock: { maxFrameMs }); runSteps(n) exists for fast-forward. Added: dev warn-once when advance() clamps.
3free() deferred; old nodes survive a stepCODE + DOCSworld.flushFree() made public + documented for scene swaps; deferred lifecycle documented.
4actionsDown Set silently failsCODEstep() accepts any iterable (already true at HEAD) and now throws a clear error on non-iterable/string input. Cross-realm Sets iterate fine via for…of.
5No between(lo, v, hi)WONT-FIXTwo clamps with swapped argument orders is a worse footgun than the porting friction it saves. clamp(v, lo, hi) documented; porting note added.
6First-frame step accumulation sinks playerDOCSGame-logic guard issue; documented “first advance can run multiple steps” note.
7No scene-tree inspectorCODEworld.walk(fn), world.nodeCount, world.debugTree().
8Stringly-typed resourcesCODEworld.resource<T>(key) throwing accessor (typo → loud error, not silent undefined); world.state is the typed home for shared state (see theme 7).
9World not reachable from runBrowserFIXED@HEADGameHandle.world exists.
10flushFree() undocumentedCODEPublic + documented (see 3).
11Fixed dt not configurableFIXED@HEADclock: { hz } config.
12No collision utilitiesFIXED@HEADphysics/: aabb, tilemap, platformer, raycast, spatialHash, rigid body suite.
13No draw layers / z-indexFIXED@HEAD + CODEz-sort is global (painter’s sort), not tree-order; plus new layer pass for overlays. Documented.
14No subtree pause / process modeCODEworld.paused + node.pauseMode: 'inherit' | 'always' | 'stopped'; paused world still renders. Plus world.timeScale. Both hashed/snapshotted (only when non-default, so pinned hashes survive).
15wt vs IDENTITY in draw() undocumentedDOCSdraw(out, wt) contract documented: wt is camera-inclusive and self-inclusive; screenSpace replaces most IDENTITY hand-rolling.

ninja-cat (Triska)

#EntryVerdictAction
1.1No arc shapeCODEarc DrawCommand + Sprite shape (open arc or sector pie).
1.2No ellipseCODEellipse DrawCommand + Sprite shape.
1.3No lineDashCODEPaint.lineDash (Canvas setLineDash / SVG stroke-dasharray).
1.4No gradient fillsFIXED@HEADPaint.gradient exists (bounding-box space); documented per ptg 19.
1.5No clipping/maskingWONT-FIX (for now)Rare need; big renderer-parity cost across Canvas/SVG/WebGL/headless. Revisit if a real game needs it.
2.1–2.5ZzFX param semantics mismatchCODEspecFromZzfx(params) converter encoding exactly these conversions (slide×duration, Hz→semitone jump, deltaSlide curve fit, filter sign convention). repeat (ZzFX repeatTime) added to SoundSpec synth.
2.6ZzFXM incompatibleWONT-FIX (documented recipe)Tracker→Song transcription is a porting task, not an engine seam; recipe documented (bundle zzfxm verbatim, route through audio.sfxBus). Converter is future work if a second port needs it.
2.7Two competing AudioContextsCODEaudio.ctx / audio.sfxBus / audio.musicBus exposed publicly (typed) so external synths join hayao’s context and volume graph instead of creating their own.
3.1cosmetic not typed on NodeFIXED@HEADNode.cosmetic is a typed public field.
3.2world.state untypedCODEWorld<TState> + defineGame<TState>() generics.
3.3sprite.paint undocumentedDOCSDocumented (construction props vs runtime paint).
3.4sprite.shape mutation undocumentedDOCSDocumented as supported.
4.1Camera2D minimalFIXED@HEADCameraController (follow, deadzone, smoothing, bounds).
4.2No screen-space HUD layerCODEnode.screenSpace = true: subtree renders in design coordinates, above the world (layer pass).
4.3Zoom default guidanceDOCSCamera recipe: design-res vs zoom, what 0.7 means.
4.4HUD offsets compress at zoom≠1CODESolved by screenSpace (no more camX+offset math).
4.5World-space radii vs screen perceptionDOCSDocumented pattern: divide interaction radii by zoom when the affordance is perceived in screen space.
5.1No scene clear APICODEnode.clearChildren() (frees children safely).
5.2Parent rotation affects children (undocumented)DOCSTransform semantics documented (incl. nve 12.1/12.2 flip/pivot patterns).
6.1Determinism constraint surfaced lateCODEOpt-in WorldConfig.guardDeterminism: during step(), Math.random/Date.now warn once with a stack. (Auto-enabling it in capture/workshop harnesses is follow-up work.)
7.0Unknown action names silent; prevPointerDown leakCODE + WONT-FIXWarn-once when querying an action name no source has declared. The prevPointerDown leak is game state (wont-fix), but justPressed/justReleased cover the underlying need.
7.1No justReleasedFIXED@HEADinput.justReleased() exists.
7.2Pointer coords in design space undocumentedDOCSDocumented (+ world.screenToWorld).
7.3No velocity-cap guidanceDOCSBest-practice note (rigid body suite already caps; kinematic recipes note the clamp).
8Missing utilities (easing, verlet, particles, shake, tween)FIXED@HEAD + CODEEASINGS/tween/springs, Particles, Shaker all exist; VerletChain added for the rope/streamer gap; segment-circle etc. live in physics.
9.1/9.2No property reference / SoundSpec unitsDOCSdocs/API.md is the generated surface; SoundSpec units already in synth.ts docstrings — audio doc section cross-links them.
10.1No path-lerp for bezier morphsWONT-FIX (for now)Niche; author two paths and crossfade opacity, or poly-lerp. Revisit with real demand.
10.2No skeletal/rope/chain nodeCODEVerletChain node: N-segment verlet, pinned head (optional tail), gravity/damping/length, per-segment angle accessor for sprite mapping.
10.3State/view split for charactersDOCSDoctrine already (pure logic + cosmetic view); character example noted.
10.4No spritesheetWONT-FIXVector-first engine by design; TextureSprite/PixelBuffer cover raster needs.
10.5Rotation pivot implicitCODENode.pivot (local point that pos/rotation/scale anchor to; serialized only when set, so hashes stay pinned).
10.6No shadow primitiveFIXED@HEADPaint.shadow exists (blur/dx/dy). Documented.

ninja-vs-evilcorp

#EntryVerdictAction
1.1rAF throttled in iframeCODEBuilt-in hidden-tab ticker (see squad 1).
1.2?capture undocumentedDOCSDocumented in VERIFICATION.md + QUICKSTART pointer.
1.3probe() too manualDOCS__hayao.world + probe override documented.
2.1Forbidden APIs surfaced lateCODEguardDeterminism (see ninja-cat 6.1).
2.2world.rng unsafe from cosmetic nodesFIXED@HEAD + DOCSrng.split(key) + private-Rng pattern (Particles/Shaker do this); documented. hashNoise() added for stable per-entity variation (see ptg 8).
3.1Camera transform formula undocumentedDOCSFormula + worldToScreen/screenToWorld documented.
3.2No screen-space HUDCODEscreenSpace (see theme 1).
3.3Camera scroll tracking manualFIXED@HEADCameraController.
4.1world.state untypedCODEGenerics (theme 7).
4.2WorldContext lacks world/stateCODEctx.state, ctx.events, ctx.width/height, ctx.paused/timeScale, ctx.camera() added to WorldContext.
4.3/4.4Particles/Shaker untypedFIXED@HEADTyped burst()/addTrauma() at HEAD.
4.5poly shape missing from typesFIXED@HEADIn the Shape union.
4.6Camera2D.current untypedFIXED@HEADTyped field.
4.7KENTO opaqueDOCSPalette doc pointer in API digest.
5.1First frame renders before onUpdateDOCSSet visible:false in build; documented. (Auto-stepping before first render would shift every recorded replay by one frame — not worth it.)
5.2Parent rotation rotates child framesDOCSTransform semantics section (see ninja-cat 5.2).
5.3find() is O(n) per callDOCS”Cache refs at build/onReady” note; world.walk for audits.
5.4z conventions unclearDOCSGlobal painter’s sort documented + layer pass.
6.1No tilemap collisionFIXED@HEADphysics/tilemap + aabb.
6.2No platformer physicsFIXED@HEADphysics/platformer (coyote, buffer, apex, wall-slide…).
6.3No vision/LOSFIXED@HEADphysics/raycast (DDA, cones).
6.4No localStorage helperFIXED@HEADpersist/storage adapters + SaveManager.
6.5Audio: no loops/ambientFIXED@HEADplaySong({loop}), startAmbient(), full music stack.
6.6No continuous particlesFIXED@HEAD + CODEAmbientField covers weather; Particles.emit(rate) continuous mode added for attached emitters (smoke/sparks).
7.1HUD pos every frameCODEscreenSpace.
7.2inputMap as anyCODEInputMap accepts readonly string[] bindings.
7.3splash:false undocumentedDOCSQUICKSTART mention.
7.4Float rows in level messagesWONT-FIXGame data choice.
8.1200 sprites for rainFIXED@HEADAmbientField.
8.2Vision cone recomputeWONT-FIXGame-side caching; engine primitives are pure functions by design.
8.3find() per frameDOCSSee 5.3.
9Documentation gaps listDOCSAll items covered by the docs pass.
10.1z appears sibling-localFIXED@HEAD + DOCSSort is global; report was a misdiagnosis — but it was possible because nothing documented it. Documented + layer pass guarantees overlays.
10.2LetterboxingFIXED@HEAD + DOCSBy design; handle.toDesign()/viewport() documented.
11.1Position-driven movement bypasses collisionDOCSRecipe: pair every position write with a resolve (platformer module does this internally).
11.2burst() coordinate spaceDOCSLocal-to-the-node space documented.
12.1/12.2Rotation affects children only; author un-flippedDOCSCharacter-group pattern documented; Node.pivot helps.
13.1Y-axis convention silentDOCS”Y increases downward” stated in camera/coords docs.
14.1No bbox/pivot debug overlayCODE (partial)world.debugTree() covers tree audits; visual bbox overlay logged as future work (needs renderer pass).
15.1Keyframe timer conflationWONT-FIXGame-logic bug; PhaseClock/Fsm exist for this. Testing suggestion (probe enemy state after N seconds) already supported by runSteps + probe.

path-to-glory

#EntryVerdictAction
1WorldContext missing .stateCODEctx.state added (theme 7).
2No clip/dash/gradient/compositeCODE + FIXED@HEAD + WONT-FIXlineDash added; gradients existed; clip + composite deferred (see ninja-cat 1.5).
3No right-mouse inputCODEpointer.right / pointer.middle axes + contextmenu suppression in PointerSource.
4IDENTITY-for-HUD undocumentedCODE + DOCSscreenSpace + draw contract docs.
5No nonlinear zoom curveDOCSRecipe: keep logical zoom in state, map to cam.zoom in one cosmetic place. Engine indirection would hide the real render scale that pointer math needs.
6No coroutine/async flowCODECoroutines (generator-based, deterministic, concurrent).
7npm create hayao 404PARTIALWas mis-marked fixed: a create-hayao bin inside hayao does NOT satisfy npm create hayao (npm resolves a standalone create-hayao package). Fixed for now via npx hayao create <name> (subcommand → same scaffolder); npm create hayao needs a standalone create-hayao package published (Option A, later).
8No seeded per-entity RNG for drawCODEhashNoise(…values) — stateless stable float, safe in draw().
9rAF in previewCODEBuilt-in ticker (squad 1).
10No pointer.justDownCODEPointer buttons feed the action pipeline (mouse.left/right/middle bindable in inputMap → justPressed works); axes stay for analog reads.
11No z-layer groupingCODE + DOCSlayer field + global-sort docs.
12No procedural audio APICODEPublic audio.ctx/buses (ninja-cat 2.7).
13Module state persists across build()DOCSWith ctx.state+generics there is no longer a reason for module globals; documented.
14composeTransform orderDOCSDocstring: “applies n first, then m (M·N)“.
15Transform utils undiscoverableFIXED@HEADIn API digest; docs cross-link.
16AudioBus fields privateCODEPublic typed getters (see 12).
17/27Single-slot waitForCODECoroutines supports unlimited concurrent runners + race/all.
18No time scaleCODEworld.timeScale (hashed when ≠1; scales dt handed to the tree; pauseMode:'always' nodes ignore it).
19Gradient docsDOCSBounding-box space documented.
20No typed findChild<T>CODEnode.findOfType(Ctor) / world.findOfType(Ctor) typed helpers.
21children mutates during freeFIXED@HEADfree() defers to end-of-step; updateTree iterates a copy. Documented.
22pos vs x/y duplicationCODEnode.x/node.y accessors backed by pos.
23Charge ratio lostWONT-FIXGame architecture.
24zoom is render scaleDOCSSame as 5.
25No regen/wrap utilityWONT-FIXNiche; ctx.camera() (new) removes the sibling-search pain that motivated it.
26No camera in WorldContextCODEctx.camera(){ pos, zoom } | null.
28No pauseCODEworld.paused + pauseMode (squad 14).
29onUpdate undocumentedDOCSDocumented.
30No onReadyFIXED@HEADonReady() exists.
31addChild returns voidFIXED@HEADReturns the child, typed.
32No handle.tick()CODEhandle.tick(dtMs?): pointer sample + advance + render in one call.
33RAF patch self-inflicted bugsCODERoot cause removed: engine-owned ticker fallback; no more per-game patches.
34Renderer dies on bad radiusCODECanvas renderer skips non-finite/negative primitives with a warn-once naming the command; per-command isolation so one bad draw can’t kill the loop.
35draw() transform self-inclusiveDOCSContract documented on Node.draw + engine docs.
36No transition primitiveFIXED@HEADScreenTransition (fade/iris/dither) + CinematicPlayer. Documented.

Release note

Everything above lands as hayao 0.4.0. The four recreations pinned 0.3.0; the single biggest meta-friction is that half their pain was already fixed on main but unreleased — cut releases more often.

This page rendersthe repo's markdowndirectly — edit it there, it changes here.