Skip to content

30b-vr-play: the game in your hands — VR game panel, sweep, haptics, sound, effects - #245

Merged
AlexZ005 merged 8 commits into
feat/1.17from
feat/30b-vr-play
Sep 23, 2026
Merged

AlexZ005 merged 8 commits into
feat/1.17from
feat/30b-vr-play

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

Roadmap 30 round 2 ("30b — the Quest round"), lane 30b-vr-play: contracts C2–C6 from the user's Quest 3 feedback.

What landed (one commit per phase)

  • P0 C5 sound + music — 20 procedural game sounds (api.playSound('coin'), an unknown name is now a quiet no-op), api.music.play/stop with 7 procedural looping presets tempo-synced to the session clock, refused in Edit and stopped on leaving Interact/Play; Settings ▸ Interface ▸ Sound: "Game sounds" + "Music" (local).
  • P1 C4 haptics — every pulse is silent in Edit; api.hapticPattern(name, hand?) (tap/bump/hit/success/fail/rumble/heartbeat); core defaults in Interact/Play: hover tap, press bump, grab hit, knock scaled by speed.
  • P2 C6 effects + announce — api.effects.burst(pos, {kind, color, count}) (pooled, scene-root, local); api.announce(text, {sub, ms, color}) (desktop banner; head-locked in VR).
  • P3 C2 the game UI in VR — a game's menu screens on a world board (cropped to the menu, lazy head-yaw follow), overlays as a left-wrist card + a head-locked top strip, laser + trigger + poke, an Edit mode button on board and wrist.
  • P4 C3 hold-and-sweep — tip + laser sweep while the trigger is held in Interact/Play, ctx = {source, mode} on every click handler, registerClickHandler(fn, {sweep: false}); an Interact VR press clicks and never selects.
  • P5 docs — MODULES.md "Game feel" + llms-full refresh.

Suites

suite (final head, fresh server) PASS lines / FAIL
game-audio (new) 25 / 0
game-haptics (new) 18 / 0
game-effects (new) 24 / 0
vr-game-panel (new, incl. the real Towers template) 35 / 0
vr-sweep (new, incl. the real Jam Room + a 2.2 cm keyboard) 23 / 0 (2 of those are the module-install premises)
held: hud-actions 65 / 0
held: game-editor-flow 41 / 0
held: sdk-game-seams 78 / 0
held: module-toolbox 46 / 0
held: editor-modes 45 / 0

(The P3/P4 commit bodies say 36 and 24 — the PASS-line counts above are the exact ones.)
One more fix after P5: the FIRST burst of a session was lost to a first-draw shader compile; the pool now compiles its material when built.

vitest 233 (base 222). svelte-check 334/47 (base 335/47, one pre-existing implicit-any removed, identical list otherwise). Build green. Every commit body lists its counterfactuals.

Judgement calls (QUESTIONS-30b-vr-play.md)

  1. api.music.play from Edit is refused. 2. The sweep tip fires the NEAREST touching target (a 6 cm sphere is wider than a piano key). 3. Board vs wrist is split per screen.

For the integrator (with 30b-vr-modes)

  • vr-modes' mode tick → hapticPulse(0.35, 40, hand, true); my grab hapticPattern('hit', hand) folds into their onSqueezeStart; my wrist card clears their label; App.svelte tails: union gameKit with their moduleWorld + playSpawn.
  • Outside my files (minimal): flowRuntime objectHasOnClick + fireObjectClick count; Scene start/stop of vrGameInput, haptic: hapticKnock, Interact fallthrough → interactClick; HudLayer banner.
  • Jam Room: music-fx's footswitch / mixer mute have no click handler, so a sweep has nothing to flip there; a module-side handler makes them sweepable for free.

Owed on device (Quest 3)

Haptic feel and knock scale; music vs sfx level; board size/distance/follow, wrist placement and turn-to-read threshold, strip height; the sweep on a real piano / drum grid; poke depth; canvas text crispness.

🤖 Generated with Claude Code

AlexZ005 and others added 8 commits September 23, 2026 14:03
…seven music presets, two local volumes

- gameSfx.js (leaf): twenty procedural WebAudio effects with no asset files - click, pop,
  whoosh, success, fail, hit, kick, shoot, laser, explosion, coin, levelup, goal, whistle,
  cheer, step, ring, sparkle, hurt, portal - on the app's one AudioContext, through ONE
  "Game sounds" gain into the engine's sfx bus; a position spatialises it; a 24-voice cap
  so a sweep or a barrage cannot stall the audio thread. renderGameSound measures a sound
  offline (the suite's proof that every name makes one).
- gameMusicPresets.js (pure, vitest) + gameMusic.js: arcade / ambient / dungeon / stadium /
  space / puzzle / studio as four-bar loops of data, scheduled with a 25 ms lookahead
  timer (not rAF - an immersive session stops window rAF) and numbered off the SESSION
  clock, so two peers on one preset hear the same bar. Quiet under the effects (MIX 0.55).
  Refused in Edit (a module starting music from register() must not score the editor)
  and stopped on the falling edge of Interact/Play.
- gameFeel.js (leaf): the ONE "is the player playing" predicate (isLocked === true or
  editorMode === 'interact') every piece of game feel reads.
- moduleSDK: api.playSound routes a game name to the new set, keeps the ping chimes
  (ding/chime/pluck/bell), and an UNKNOWN name is now a quiet no-op returning false (it
  used to fall through to the ding) - the C5 contract; api.music.play/stop/current/presets.
- Settings > Interface > Sound: "Game sounds" and "Music" sliders, LOCAL, persisted via
  safeStorage (game:soundVolume / game:musicVolume).
- gameKit.js: the new leaves under ONE debug-hook name (App.svelte tails 215/215/215).

Suite game-audio (new): 25/25. vitest gameMusicPresets 6/6 (all vitest 233 green).
Counterfactuals (all four broken in one run, each red on its own checks):
- the stop-on-leave subscription removed -> 7.1, 7.2, 8.2 red (music kept playing, peak 0.0597)
- the Edit refusal removed -> 5.1, 5.2 red (music in the editor, peak 0.0751)
- the music volume no longer reaching the live gain -> 9.2 red
- the coin built as silence -> 1.2 red (quiet: ["coin"])
Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0 (module-toolbox and
editor-modes waited on the machine lock; run at P1). svelte-check 335/47, identical list to base. Build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…bump/hit/knock defaults in Interact/Play

- vrControls.hapticPulse is now a NO-OP in Edit (gameFeelActive), the one funnel every
  core pulse and api.haptic go through - "the vibration should be only interactive mode,
  not in edit mode" (the user, Quest 3). A 64-pulse debug ring + a suppressed count
  (hapticDebug) is the suites' view, since no headset is attached headless.
- hapticPatterns.js (pure, vitest): tap, bump, hit, success, fail, rumble, heartbeat as
  timed, never-overlapping pulses (a new actuator pulse REPLACES the running one), a
  scale, and knockHapticScale. vrControls.hapticPattern plays one through setTimeout,
  each pulse re-gated, so leaving the game mid-rumble stops it.
- api.hapticPattern(name, hand?) -> boolean (false in Edit / unknown name).
- defaults: a VR grip GRAB lands with 'hit'; a KNOCK plays 'hit' sized by its closing
  speed (hapticKnock, the knock seam Scene hands knock.js); vrGameInput.js (new, plugs in
  through vrControls' frame + trigger hook registries, started from Scene) taps a hand
  when its laser ENTERS something clickable and bumps it when the trigger lands there.
  "Clickable" is one resolver for tap, bump and the P4 sweep: module interactive-group
  content, a part of a module device (userData.device - the piano key, not the piano),
  an On Click target, or userData.clickable === true.
- flowRuntime: objectHasOnClick(uuid) (fireObjectClick's own two rules), and
  fireObjectClick returns how many nodes it reached (additive).

Suite game-haptics (new, a stubbed XR session with recording actuators): 18/18.
vitest hapticPatterns 5/5 (all vitest 238 green).
Counterfactuals (four guards broken in one run):
- the Edit gate removed -> 1.1, 1.2, 3.4 red (3 pulses in Edit; rumble played 4 of 4 after leaving)
- the hover edge removed (tap every frame) -> 4.2 red (2 extra taps on one target)
- every object treated as clickable -> 4.3, 5.2 red
- the knock scale ignored -> 6.2 red (0.850 -> 0.850)
Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0, module-toolbox 46/0,
editor-modes 45/0.
svelte-check 334/47 (base 335: the JSDoc on hapticPulse's forEach removed a pre-existing
implicit-any; no new message). Build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…i.announce banner

- effectsBurst.js: api.effects.burst([x,y,z], {kind, color, count}) - sparkle / confetti /
  smoke / sparks as a POOL of 12 THREE.Points systems (96 particles each) at the scene root
  (never objectsGroup - golden rule 5), recycled oldest-first, LOCAL (nothing on the wire).
  Deterministic spread (a golden-angle spiral), per-kind motion (gravity, drag, rise,
  swell), additive kinds fade by darkening. Ticked from the flow runtime's frame loop (it
  runs in a headset, where window rAF stops) and leaving it when idle; the AGE is real
  time, so a burst lives its life whatever the frame rate (a throttled tab ticks ~5/s).
- gameAnnounce.js (leaf) + api.announce(text, {sub, ms, color}): a big centred banner,
  ONE at a time (a new one replaces), gone by itself; HudLayer draws it on the desktop
  (#game-announce, pointer-events none, stands down in VR - P3 draws it head-locked).
- a scene clear (runSceneClearHandlers) takes the live bursts and the banner with it.
- vrControls.hapticPulse gains an additive `force` (4th arg) for 30b-vr-modes' Edit/Interact
  switch tick, the one pulse that must be felt IN Edit (asked for by that lane).

Suite game-effects (new): 22/22.
Counterfactuals (four broken in one run):
- the burst never joins the frame loop -> 3.1, 3.2 red, and 6.1 (the frame changed 60 px, not 3709)
- the pool grows without bound -> 4.1, 4.2 red (21 systems for 20 bursts)
- the banner timer never clears -> 7.4 red
- the scene-clear hooks removed -> 8.2 red
Held: hud-actions 65/0, game-editor-flow 41/0, sdk-game-seams 78/0, module-toolbox 46/0,
editor-modes 45/0. svelte-check 334/47, no new message. Build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, the wrist card, the top strip, the banner

- vrGamePanel.js: DOM is invisible in a headset, so the game's HUD documents are drawn
  into CANVAS TEXTURES on four scene-root meshes (default layer = both eyes, never the
  helper layer; LOCAL; only while presenting AND in Interact/Play):
  - the BOARD: a menu-like screen (input 'menu', a control on it, or bound to menu /
    paused / over) laid out with the desktop's own 9-grid (rectInFrame over the 1280x720
    stage), CROPPED to the part of the stage the screen uses and sized from it (1.6 mm per
    stage px, 1.15 m for the Towers menu) - drawn whole it was a wall of empty backdrop
    with the menu small in the middle. 1.2 m ahead at chest height, following the head
    LAZILY with hysteresis: a glance inside 35 degrees leaves it, a real turn brings it all
    the way back in front (without the latch it parked 28 degrees off to the side). A
    footer of ours: "Edit mode" (objectActions.setEditorMode('edit')) and "Top strip".
  - the WRIST card: the other screens (score, timer, level) as short lines on the LEFT
    wrist, shown when the wrist turns toward the face, with the same two buttons - a way
    back to editing mid-round. Placed clear of 30b-vr-modes' mode label.
  - the TOP STRIP: the glanceable readouts in one head-locked row (a sentence-long hint
    stays on the wrist), LOCAL on/off (vr:gameStrip), default on.
  - the ANNOUNCE banner, head-locked (api.announce in a headset).
  A press is the desktop press verbatim (fireHudButton; a toggle writes its value
  first; sliders/dropdowns/tabs step), with a 'click' sound and a 'bump'.
- vrGameInput wires it: the frame hook draws, hovers (the laser on the board wins over the
  world behind it) and POKES (the tip through a button presses once, re-arms on retreat);
  the trigger hook presses a board button and CONSUMES the press, and the trailing
  'select' is swallowed once; two beam providers end the laser on the board/wrist.

Suite vr-game-panel (new): 36/36, including section 13 on the REAL Towers template: its
menu on the board, "Start round" pressed by laser starts the round, its HUD on the
wrist/strip. Evidence: /home/deck/.code/lanes-30/after-30b/30b-vr-play/.
Counterfactuals (five broken in one run):
- the Interact/Play gate removed -> 8.2 red (the wrist and strip stayed up in Edit)
- the board press not consuming -> 3.2, 8.1 red
- the poke never re-arming -> 6.3 red
- the crop removed -> 1.6 red (a 2.05 m board)
- the follow latch releasing at the band edge -> 9.3 red (parked at 52.6 of 80 degrees)
svelte-check 334/47, no new message. Build green. Held suites run once for P3+P4
(machine-wide e2e queue of 11 lanes); see the P4 commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…in Interact/Play, ctx.source, {sweep: false}

- vrGameInput: while the trigger is held in Interact/Play, the controller TIP (a 6 cm
  sphere just ahead of the controller) clicks the clickable it becomes NEAREST to, once,
  spent while still touched and re-armed on leaving; with the tip touching nothing, the
  LASER does the same for targets out of reach (> 25 cm) and for the game board's buttons.
  The first entry is the press itself (source 'trigger', fired on the PRESS so an
  instrument sounds when hit), later entries are 'sweep'. The sweep's hook does not
  consume; on its first frame it reads vrControls.triggerClaimed(index) and stands down
  under a gesture another hook claimed (knob drag, cable, sleeve). A press that clicked
  anything swallows its trailing 'select'.
- nearest-only is a deliberate reading of "every clickable the tip ENTERS": a 6 cm sphere
  is wider than a 2.2 cm key and the literal reading played clusters (QUESTIONS fork 2).
- moduleSDK: every click handler gets ctx = {source, mode} (additive); a handler
  registered with {sweep: false} still hears the press and never a sweep.
- vrControls: triggerClaims per slot + triggerClaimed(index) (additive).
- Scene: in Interact/Play the VR trigger's final fallthrough is interactClick (handlers,
  On Click, the miss) instead of raycastSelect - an Interact press selects nothing
  (30b-vr-modes handed this path to C3).

Suite vr-sweep (new): 24/24, including the REAL Jam Room (a held sweep over three piano
keys plays key-64, key-65, key-67 once each; over three drum steps flips each).
Counterfactuals:
- re-arm removed -> 2.1 red; {sweep:false} ignored -> 3.1 red; the swallow never set ->
  4.1, 5.2 red; the claim check removed -> 6.1 red; the Interact fallthrough removed ->
  9.1 red (the release selected the object) [one run, five broken]
- every touched target fired (no nearest rule) -> 11.1 red (["n1","n0","n2","n3","n4","n5","n6"])
svelte-check 334/47, no new message. Build green. Held suites for P3+P4 NOT yet re-run
(budget checkpoint; P2's held run was green) - owed before the PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, banners), the VR board, hold-and-sweep

- a "Game feel" section with a ten-line example: api.playSound's game set (and the
  unknown-name no-op), api.music, api.hapticPattern (and Edit silence + the core
  defaults), api.effects.burst, api.announce; how a game's HUD reaches the VR board,
  wrist card and strip with nothing to do on the module side; the hold-and-sweep, what
  counts as clickable, ctx = {source, mode} and {sweep: false}.
- the Interaction section: the VR trigger now follows the editor modes.
- Misc: api.hapticPattern beside api.haptic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sync-llms)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ecks wait on the thing

- effectsBurst: the Points program was compiled on the FIRST draw that needed it, and
  on a loaded device that stall ate the first burst of a session whole (measured: the
  first burst on a fresh page changed 0 pixels, every later one ~1500) - the first
  "ring reached" sparkle is exactly the one a player would miss. warmUp() compiles the
  burst material when the pool is built (renderer.compile on one system; compile()
  walks visible objects only, so it is shown for the call).
- game-effects: 1.5 asserts the program exists before any frame is drawn; the pixel
  check uses a confetti burst (2.2 s of life - a slow frame cannot outlive it) and
  samples until a frame differs instead of one fixed 120 ms sleep, with a state dump in
  its message. (While finding this, zero-pixel frames on a long-lived dev server turned
  out to be the edited-under-a-running-server dual-instance trap; a fresh server reads
  3361 px.)
- game-haptics: the pattern checks wait for the PULSES (headless timers ran ~2x late on
  a machine running two e2e slots), not a clock.
Counterfactual: warmUp removed -> 1.5 red (16 -> 16 programs).
game-effects 24/24, game-haptics 18/18 on a fresh server; svelte-check 334/47; build green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit 496460f into feat/1.17 Sep 23, 2026
4 checks passed
@AlexZ005
AlexZ005 deleted the feat/30b-vr-play branch September 23, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant