Gameplay haptics (plan)
Gameplay Haptics Implementation Plan
Section titled “Gameplay Haptics Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, consultbh-sdd-concurrentbefore Task 1 and dispatch implementers into their own worktree (.claude/worktrees/<name>, branch offorigin/main).
Goal: Per-pilot haptic feedback for gameplay events (phone vibration for a touch pilot, controller rumble for a joypad pilot) plus one persisted player-facing on/off switch that also governs the existing touch-button taps.
Architecture: A render-side HapticsDirector (RefCounted, owned by main) diffs each pilot’s state frame to frame and fires patterns through an injectable HapticsOutput (RefCounted) that maps pilot index to joypad device ids and/or the handheld vibrator. main supplies the route list every frame from the same device mapping it already uses for input, forwards render_fx_events to the director, and feeds it the Warden’s wind-up charge. One static switch HapticsOutput.enabled is the runtime truth; meta.haptics persists it; the pause menu, the Debug Settings row and the Remote Console touch:haptics key all write that one switch. /sim is not touched, so the determinism baselines cannot move.
Tech Stack: Godot 4.7.1, typed GDScript (warnings as errors), GUT 9.7.1, the Remote Console What’s new data (control/src/whats_new.js, node tests).
Spec: docs/superpowers/specs/2026-10-05-haptics-design.md
Global Constraints
Section titled “Global Constraints”- Render-side only: no file under
sim/changes exceptsim/meta_state.gd(the save model, which lives there and is “never read by Sim.tick”); the seed-1234 baselines intests/test_determinism_survival.gd,tests/test_determinism.gd,tests/test_determinism_coop.gdmust pass unchanged. - Hit taken: strength = clamp(dmg / max_hp × 4, 0.25, 1.0), 40–120 ms; per-pilot cooldown 150 ms.
- Pilot down (hp crossed from > 0 to ≤ 0): 400 ms, strong 1.0; beats “hit” on the killing frame.
- Level-up (
pending_levelupsincreased): two 30 ms taps, the second 80 ms later. - Boss wind-up (
fire_chargerising): weak rumble, strength = 0.15 + 0.35 × charge, re-armed every 100 ms, all pilots. - Boss kill (
"BOSS DOWN"reaction inrender_fx_events): 300 ms strong, all pilots. - Warp / decoy press edges: 18 ms (matches the existing touch tap).
- Pattern constants live in one const block in the director; they are NOT remote tunables in v1.
meta.hapticsdefaults totrue; a save without the key means on.- The Debug Settings row is renamed “Haptics”; the Remote Console key stays
touch:haptics;tests/test_debug_console_parity.gdstays green. - Turning haptics off immediately stops vibration on every pilot device. Pause stops all rumble.
- No new data is collected: no privacy page / ASC label change.
- GDScript: annotate
Dictionary.get()results asVariant(or an explicit type), never inferred:=; tabs for indentation. - After creating any new
class_namefile rungodot --headless --path . --importbefore a headless run. - Never pull a raw GUT run into context: use
bash scripts/check-test-count.shand grep$TMPDIR/bh-gut-last.log. Single file:godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<name>.gd -gexit 2>&1 | tail -25. - After ANY Godot run,
git status; restoreproject.godotandaudio/bus_layout.treswithgit checkout --if Godot rewrote them. Never commit them. - Every new
docs/**/*.mdneeds Starlighttitle:frontmatter. - Commit messages via
git commit --file=<file>; end with the session’s attribution trailer.
Where the real code differs from the spec (resolved here)
Section titled “Where the real code differs from the spec (resolved here)”Sim.boss_render_info()does not exist. The wind-up charge issim.boss_warden.render_info(sim)["fire_charge"](sim/boss_warden.gd:524-541):-1.0when not winding the barrage, else0..1.main._processalready computes it asbinfoatmain.gd:2111; main copies it ontohaptics.boss_chargethere.- A solo pilot is not bound to one device.
InputRouter.poll()scans EVERY playable joypad (_all_joypad_sticks/_all_joypad_button_down); only with 2+ pilots does main callpoll_device(playable[k])(main.gd:1583-1587). So the route for a solo P1 is all playable pads; pilot k in co-op is[playable[k]].HapticsOutput.routes_for()encodes exactly that. - Touch pilot vs. a connected pad. Spec: “P1 while TouchControls is active”. Resolved: P1 gets the handheld only when
Platform.is_touch() and not Platform.force_touchAND no playable pad is connected (a pad, when present, is the input device and gets the rumble). - Warp/decoy taps on touch would double-buzz.
TouchControls.fire_dash/fire_decoyalready call_haptic(18, …)on the press. The director’s warp/decoy pulses passhandheld_ok = false, so they only reach pads. - Decoy edge is not tracked in main. Main only edge-tracks
dash_cd(_last_dash_cd,_last_dash_cd2). The director detects the decoy launch itself fromPlayerState.drone_chargedropping from ≥ 1.0 to < 0.5 (DroneDirector.update_dronessets it to 0 on deploy,sim/drone_director.gd:113-115), and the warp fromdash_cdrising from ≤ 0 past 0.1 (main’s own rule,main.gd:1622). Works for any pilot count. - “Skip while paused” would drop the level-up buzz. The level-up panel opens (
_paused_for_levelup = true) in the same framepending_levelupsrises, and death setsgame_overin the frame hp hits 0. Resolved: one-shot patterns still fire on the frame a pause begins (the sim is frozen while paused, so a diff can’t produce new one-shots later); a pause edge stops all rumble first, and the continuous boss wind-up is held while paused. The wind-up also only re-arms while the charge is strictly rising, so a tutorial dialogue freeze (not a “pause” flag) can’t leave it buzzing. - Attract mode. Survival auto-plays as an attract demo (
_auto = true,main.gd:1447); rumbling a pad during the demo would be wrong._auto or _capturingmutes output (state is still tracked, so nothing bursts when a human takes over). - Director is a RefCounted, not a Node.
main._new_runand_return_to_menusweepget_children(); a RefCounted owned bymainsurvives that without special-casing, and main drives it explicitly (it needsrender_eventsmain has already drained), so a Node buys nothing. - Pause-menu height. The glass pause column measures 646 px of its 660 px budget (
test_glass_column_with_dev_rows_fits_720p, measured 2026-10-05 on this branch). A full-width “Haptics” row (+47 px) fails that test. Resolved: Haptics shares a half-row with Bestiary, using the same mechanics as “Send feedback | Diagnostics” (refactored into one_half_row()helper, with left/right stepping generalised to any registered pair). When Bestiary is hidden (tutorial) the Haptics button sits full width alone. Chris eyeballspause.pngbefore it is blessed. - Debug/Console writes bypass main.
DebugSettingsPanel.set_touch_staticis static and has nometa. Resolved: every writer flipsHapticsOutput.enabled;main._sync_haptics_pref()(top of_process, before thesim == nullreturn) persists any difference tometa.hapticsand stops rumble when it went off. - Progress reset plan.
docs/superpowers/plans/2026-10-04-progress-reset.md(not yet built) lists the fields a wipe keeps (install_id,share_diagnostics, …). IfSaveResetexists on main when this executes, addhapticsto its keep-list in Task 3 (a device preference, not progress).
File Structure
Section titled “File Structure”| File | Status | Responsibility |
|---|---|---|
input/haptics_output.gd |
Create | class_name HapticsOutput: the one enabled switch, per-pilot routes, routes_for(), pulse(), stop_all(), overridable Input seams. (input/ is already symlinked into platform/tvos/.) |
input/haptics_director.gd |
Create | class_name HapticsDirector: pattern consts, per-pilot frame diffing, level-up second-tap queue, boss wind-up, boss kill. |
tests/haptics_spy.gd |
Create | class_name HapticsSpy extends HapticsOutput: records calls instead of touching Input. |
tests/test_haptics_output.gd |
Create | Routing tests. |
tests/test_haptics_director.gd |
Create | Detector/pattern tests. |
tests/test_haptics_pref.gd |
Create | Persisted switch + Debug row/console binding. |
tests/test_haptics_toggle.gd |
Create | Pause-menu toggle. |
tests/test_haptics_main.gd |
Create | Main wiring. |
sim/meta_state.gd |
Modify | haptics field + save round-trip. |
meta/meta_store.gd |
Modify | set_haptics(). |
ui/touch_controls.gd |
Modify | Remove static var haptics; _haptic reads HapticsOutput.enabled. |
ui/debug_settings_panel.gd |
Modify | Row label “Haptics”, desc, getter/setter → HapticsOutput.enabled. |
ui/pause_menu.gd |
Modify | haptics_toggled signal, haptics_on, _haptics_btn, _half_row(), generalised _pair_step(). |
main.gd |
Modify | Boot read, director ownership, _drive_haptics(), _sync_haptics_pref(), _on_haptics_toggled(), pause wiring, resets, boss charge. |
tests/test_debug_console_parity.gd, tests/test_main.gd, tests/test_meta_state.gd, tests/test_pause_menu.gd |
Modify | Follow the rename / new button. |
tools/ui_sweep/baseline/pause.png |
Modify | Re-blessed after eyeball. |
docs/architecture/coop-and-pilots.md, docs/architecture/dev-tools.md, control/src/whats_new.js |
Modify | Docs, What’s new, feel-check checklist. |
Task 1: HapticsOutput — the switch and per-pilot routing
Section titled “Task 1: HapticsOutput — the switch and per-pilot routing”Files:
- Create:
input/haptics_output.gd - Create:
tests/haptics_spy.gd - Test:
tests/test_haptics_output.gd
Interfaces:
-
Consumes: nothing.
-
Produces:
static var HapticsOutput.enabled: bool(defaulttrue) — THE haptics switch.var routes: Array— oneDictionaryper pilot index:{"joypads": Array (int device ids), "handheld": bool}.static func routes_for(pilot_count: int, playable: Array, touch: bool) -> Arrayfunc pulse(pilot: int, weak: float, strong: float, ms: int, handheld_ok: bool = true) -> void(no-op whileenabledis false)func stop_all() -> void(always runs, even while disabled)- Overridable seams:
func _joy(device: int, weak: float, strong: float, seconds: float) -> void,func _handheld(ms: int, amp: float) -> void,func _joy_stop(device: int) -> void HapticsSpy(tests only):var calls: Arrayof{"kind": "joy", "device", "weak", "strong", "ms"}/{"kind": "handheld", "ms", "amp"}/{"kind": "stop", "device"};func of_kind(kind: String, device: int = -1) -> Array.
-
Step 1: Write the spy and the failing tests
tests/haptics_spy.gd:
class_name HapticsSpyextends HapticsOutput
# Test double for HapticsOutput: records what would have reached Input instead of calling it.# Input.start_joy_vibration / vibrate_handheld are silent no-ops headless, so a real output proves# nothing in GUT (the EventQueue.push lesson).
var calls: Array = []
func _joy(device: int, weak: float, strong: float, seconds: float) -> void: calls.append({"kind": "joy", "device": device, "weak": weak, "strong": strong, "ms": roundi(seconds * 1000.0)})
func _handheld(ms: int, amp: float) -> void: calls.append({"kind": "handheld", "ms": ms, "amp": amp})
func _joy_stop(device: int) -> void: calls.append({"kind": "stop", "device": device})
# The recorded calls of one kind, optionally for one device only.func of_kind(kind: String, device: int = -1) -> Array: var out: Array = [] for c: Dictionary in calls: if String(c["kind"]) == kind and (device < 0 or int(c.get("device", -1)) == device): out.append(c) return outtests/test_haptics_output.gd:
extends GutTest
# HapticsOutput: pilot index -> joypad device ids and/or the handheld vibrator# (spec docs/superpowers/specs/2026-10-05-haptics-design.md, "Output routing").
var _was := true
func before_each() -> void: _was = HapticsOutput.enabled HapticsOutput.enabled = true
func after_each() -> void: HapticsOutput.enabled = _was
func test_solo_pilot_rumbles_every_playable_pad() -> void: var r := HapticsOutput.routes_for(1, [3, 5], false) assert_eq(r.size(), 1) assert_eq(r[0]["joypads"], [3, 5], "InputRouter.poll() reads every playable pad for a solo pilot") assert_false(bool(r[0]["handheld"]))
func test_coop_pilot_k_gets_playable_k() -> void: var r := HapticsOutput.routes_for(3, [3, 5], false) assert_eq(r.size(), 3) assert_eq(r[0]["joypads"], [3], "poll_device(playable[0])") assert_eq(r[1]["joypads"], [5], "poll_device(playable[1])") assert_eq(r[2]["joypads"], [], "a pilot with no pad of its own gets nothing")
func test_touch_p1_without_a_pad_vibrates_the_handheld() -> void: var r := HapticsOutput.routes_for(1, [], true) assert_true(bool(r[0]["handheld"])) assert_eq(r[0]["joypads"], [])
func test_touch_with_a_pad_rumbles_the_pad_not_the_phone() -> void: var r := HapticsOutput.routes_for(1, [2], true) assert_false(bool(r[0]["handheld"])) assert_eq(r[0]["joypads"], [2])
func test_only_p1_can_be_the_handheld() -> void: var r := HapticsOutput.routes_for(2, [], true) assert_true(bool(r[0]["handheld"])) assert_false(bool(r[1]["handheld"]))
func test_keyboard_only_is_a_no_op() -> void: var spy := HapticsSpy.new() spy.routes = HapticsOutput.routes_for(1, [], false) spy.pulse(0, 1.0, 1.0, 100) assert_eq(spy.calls.size(), 0)
func test_pulse_routes_to_joypad_and_handheld() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4], "handheld": false}, {"joypads": [], "handheld": true}] spy.pulse(0, 0.5, 0.25, 120) spy.pulse(1, 0.5, 0.25, 30) assert_eq(spy.calls, [ {"kind": "joy", "device": 4, "weak": 0.5, "strong": 0.25, "ms": 120}, {"kind": "handheld", "ms": 30, "amp": 0.5}, ])
func test_handheld_ok_false_skips_the_phone_but_not_the_pad() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4], "handheld": true}] spy.pulse(0, 0.7, 0.0, 18, false) assert_eq(spy.of_kind("joy").size(), 1) assert_eq(spy.of_kind("handheld").size(), 0)
func test_values_are_clamped() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4], "handheld": true}] spy.pulse(0, 3.0, -1.0, 10) assert_eq(spy.calls[0]["weak"], 1.0) assert_eq(spy.calls[0]["strong"], 0.0) assert_eq(spy.calls[1]["amp"], 1.0)
func test_disabled_sends_nothing() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4], "handheld": true}] HapticsOutput.enabled = false spy.pulse(0, 1.0, 1.0, 100) assert_eq(spy.calls.size(), 0)
func test_out_of_range_pilot_is_ignored() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4], "handheld": false}] spy.pulse(1, 1.0, 1.0, 100) spy.pulse(-1, 1.0, 1.0, 100) assert_eq(spy.calls.size(), 0)
func test_stop_all_stops_every_routed_pad_even_while_disabled() -> void: var spy := HapticsSpy.new() spy.routes = [{"joypads": [4, 6], "handheld": true}, {"joypads": [9], "handheld": false}] HapticsOutput.enabled = false spy.stop_all() assert_eq(spy.calls, [ {"kind": "stop", "device": 4}, {"kind": "stop", "device": 6}, {"kind": "stop", "device": 9}, ])- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_output.gd -gexit 2>&1 | tail -25
Expected: FAIL / script parse error — HapticsOutput / HapticsSpy not found.
- Step 3: Implement
input/haptics_output.gd
class_name HapticsOutputextends RefCounted
# Where a pilot's haptics go (spec docs/superpowers/specs/2026-10-05-haptics-design.md). One entry# per pilot index in `routes`: {"joypads": [device ids], "handheld": bool}. main.gd rebuilds the# routes every frame from the same device mapping it uses for input (routes_for), so a pad that# disconnects is simply absent next frame and pilots 3-4 need no change here.# Render/input-side only — never read by /sim.
# THE haptics switch: the pause-menu toggle, the Debug Settings "Haptics" row, the Remote Console# touch:haptics key and TouchControls' button taps all read/write this. main.gd loads it from# meta.haptics at boot and persists any change (main._sync_haptics_pref).static var enabled := true
var routes: Array = []
# Solo: InputRouter.poll() reads EVERY playable pad, so a solo pilot rumbles all of them.# Co-op: pilot k reads poll_device(playable[k]), so it rumbles that pad only.# The handheld (phone/tablet vibrator) is P1's only when it is a touch device with no pad# connected — with a pad, the pad is the input device.static func routes_for(pilot_count: int, playable: Array, touch: bool) -> Array: var out: Array = [] for i in pilot_count: var pads: Array = [] if pilot_count == 1: pads = playable.duplicate() elif i < playable.size(): pads = [playable[i]] out.append({"joypads": pads, "handheld": i == 0 and touch and playable.is_empty()}) return out
# One buzz for one pilot. weak/strong = the two pad motors (0..1); the handheld takes the larger# as its amplitude. handheld_ok = false for patterns the touch UI already buzzes itself (warp/decoy).func pulse(pilot: int, weak: float, strong: float, ms: int, handheld_ok: bool = true) -> void: if not enabled or pilot < 0 or pilot >= routes.size(): return var route: Dictionary = routes[pilot] var w := clampf(weak, 0.0, 1.0) var s := clampf(strong, 0.0, 1.0) var pads: Variant = route.get("joypads", []) for d: Variant in (pads as Array): _joy(int(d), w, s, float(ms) / 1000.0) if handheld_ok and bool(route.get("handheld", false)): _handheld(ms, maxf(w, s))
# Stop every routed pad now (turning haptics off, pausing, a new run). Runs even while disabled.func stop_all() -> void: for r: Variant in routes: var pads: Variant = (r as Dictionary).get("joypads", []) for d: Variant in (pads as Array): _joy_stop(int(d))
# Platform seams (overridden by tests/haptics_spy.gd).func _joy(device: int, weak: float, strong: float, seconds: float) -> void: Input.start_joy_vibration(device, weak, strong, seconds)
func _handheld(ms: int, amp: float) -> void: Input.vibrate_handheld(ms, amp)
func _joy_stop(device: int) -> void: Input.stop_joy_vibration(device)- Step 4: Import, then run the test
Run: godot --headless --path . --import > /dev/null 2>&1; git status --short
(restore project.godot / audio/bus_layout.tres if listed)
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_output.gd -gexit 2>&1 | tail -25
Expected: all 12 tests PASS.
- Step 5: Commit
git add input/haptics_output.gd tests/haptics_spy.gd tests/test_haptics_output.gdgit commit --file=<msg> # "feat(haptics): HapticsOutput per-pilot routing + the one enabled switch"Task 2: HapticsDirector — the detectors and patterns
Section titled “Task 2: HapticsDirector — the detectors and patterns”Files:
- Create:
input/haptics_director.gd - Test:
tests/test_haptics_director.gd
Interfaces:
-
Consumes:
HapticsOutput(pulse,stop_all,enabled,routes),HapticsSpy(tests),PlayerStatefieldshp,max_hp,pending_levelups,dash_cd,drone_charge(sim/player_state.gd:5-6,17,25,41). -
Produces:
var output: HapticsOutput(replaceable; defaultHapticsOutput.new())var boss_charge: float(-1 = not winding; main writes it)func note_events(events: Array) -> voidfunc update(pilots: Array, now_ms: int, paused: bool, muted: bool) -> voidfunc reset() -> void
-
Step 1: Write the failing tests
tests/test_haptics_director.gd:
extends GutTest
# HapticsDirector: per-pilot frame diffing -> patterns (spec 2026-10-05-haptics-design.md table).# PlayerState is a plain RefCounted, so no Sim is needed; a spy output records the buzzes.
var _was := truevar spy: HapticsSpyvar d: HapticsDirector
func before_each() -> void: _was = HapticsOutput.enabled HapticsOutput.enabled = true spy = HapticsSpy.new() spy.routes = [{"joypads": [10], "handheld": false}, {"joypads": [11], "handheld": false}] d = HapticsDirector.new() d.output = spy
func after_each() -> void: HapticsOutput.enabled = _was
func _pilot(hp := 100.0) -> PlayerState: var p := PlayerState.new() p.max_hp = 100.0 p.hp = hp return p
func test_first_frame_only_records_a_baseline() -> void: var p := _pilot(50.0) p.pending_levelups = 2 d.update([p], 0, false, false) assert_eq(spy.calls.size(), 0)
func test_small_hit_is_a_short_light_buzz_once() -> void: var p := _pilot() d.update([p], 0, false, false) p.hp = 95.0 # 5% of max x4 = 0.2 -> clamped up to 0.25 -> 40 ms d.update([p], 16, false, false) var j := spy.of_kind("joy", 10) assert_eq(j.size(), 1) assert_almost_eq(float(j[0]["weak"]), 0.25, 0.001) assert_almost_eq(float(j[0]["strong"]), 0.125, 0.001) assert_eq(int(j[0]["ms"]), 40) d.update([p], 400, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1, "no new damage, no new buzz")
func test_big_hit_is_full_strength_and_long() -> void: var p := _pilot() d.update([p], 0, false, false) p.hp = 70.0 # 30% x4 = 1.2 -> 1.0 -> 120 ms d.update([p], 16, false, false) var j := spy.of_kind("joy", 10) assert_almost_eq(float(j[0]["weak"]), 1.0, 0.001) assert_eq(int(j[0]["ms"]), 120)
func test_hit_cooldown_swallows_a_contact_swarm() -> void: var p := _pilot() d.update([p], 0, false, false) p.hp = 90.0 d.update([p], 16, false, false) p.hp = 80.0 d.update([p], 100, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1, "inside the 150 ms per-pilot cooldown") p.hp = 70.0 d.update([p], 166, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2, "cooldown over")
func test_cooldown_is_per_pilot() -> void: var a := _pilot() var b := _pilot() d.update([a, b], 0, false, false) a.hp = 90.0 d.update([a, b], 16, false, false) b.hp = 90.0 d.update([a, b], 32, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1) assert_eq(spy.of_kind("joy", 11).size(), 1, "P1's cooldown does not mute P2")
func test_pilot_down_beats_hit_on_the_killing_frame() -> void: var p := _pilot(20.0) d.update([p], 0, false, false) p.hp = 0.0 d.update([p], 16, false, false) var j := spy.of_kind("joy", 10) assert_eq(j.size(), 1, "one buzz, not hit + down") assert_eq(int(j[0]["ms"]), 400) assert_almost_eq(float(j[0]["strong"]), 1.0, 0.001)
func test_a_dead_pilot_does_not_keep_buzzing() -> void: var p := _pilot(20.0) d.update([p], 0, false, false) p.hp = 0.0 d.update([p], 16, false, false) p.hp = -5.0 d.update([p], 400, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1)
func test_heal_does_not_buzz() -> void: var p := _pilot(50.0) d.update([p], 0, false, false) p.hp = 60.0 d.update([p], 16, false, false) assert_eq(spy.calls.size(), 0)
func test_level_up_is_two_taps_80ms_apart() -> void: var p := _pilot() d.update([p], 0, false, false) p.pending_levelups = 1 d.update([p], 100, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1) assert_eq(int(spy.of_kind("joy", 10)[0]["ms"]), 30) d.update([p], 150, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1, "second tap not due yet") d.update([p], 180, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2, "second tap 80 ms later") d.update([p], 400, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2, "exactly two")
func test_picking_the_upgrade_does_not_buzz() -> void: var p := _pilot() p.pending_levelups = 1 d.update([p], 0, false, false) p.pending_levelups = 0 d.update([p], 16, false, false) assert_eq(spy.calls.size(), 0)
func test_warp_and_decoy_edges_rumble_pads_but_never_the_phone() -> void: spy.routes = [{"joypads": [10], "handheld": true}] var p := _pilot() p.drone_charge = 1.0 d.update([p], 0, false, false) p.dash_cd = 2.0 d.update([p], 16, false, false) var j := spy.of_kind("joy", 10) assert_eq(j.size(), 1) assert_eq(int(j[0]["ms"]), 18) assert_almost_eq(float(j[0]["weak"]), 0.7, 0.001) p.drone_charge = 0.0 d.update([p], 32, false, false) j = spy.of_kind("joy", 10) assert_eq(j.size(), 2) assert_almost_eq(float(j[1]["weak"]), 0.55, 0.001) assert_eq(spy.of_kind("handheld").size(), 0, "TouchControls already buzzes its own buttons")
func test_dash_cooldown_ticking_down_is_not_a_warp() -> void: var p := _pilot() p.dash_cd = 2.0 d.update([p], 0, false, false) p.dash_cd = 1.9 d.update([p], 16, false, false) assert_eq(spy.calls.size(), 0)
func test_boss_kill_reaches_every_pilot() -> void: var a := _pilot() var b := _pilot() d.update([a, b], 0, false, false) d.note_events([{"kind": "reaction", "name": "BOSS DOWN", "pos": Vector2.ZERO, "element": -1}]) d.update([a, b], 16, false, false) for dev: int in [10, 11]: var j := spy.of_kind("joy", dev) assert_eq(j.size(), 1, "device %d" % dev) assert_eq(int(j[0]["ms"]), 300) d.update([a, b], 32, false, false) assert_eq(spy.of_kind("joy").size(), 2, "the kill is consumed once")
func test_other_reactions_do_not_buzz() -> void: var p := _pilot() d.update([p], 0, false, false) d.note_events([{"kind": "reaction", "name": "NUKE", "pos": Vector2.ZERO, "element": -1}, {"kind": "death"}]) d.update([p], 16, false, false) assert_eq(spy.calls.size(), 0)
func test_boss_wind_up_rumbles_all_pilots_while_the_charge_rises() -> void: var a := _pilot() var b := _pilot() d.update([a, b], 0, false, false) d.boss_charge = 0.0 d.update([a, b], 16, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1) assert_eq(spy.of_kind("joy", 11).size(), 1) assert_almost_eq(float(spy.of_kind("joy", 10)[0]["weak"]), 0.15, 0.001) assert_almost_eq(float(spy.of_kind("joy", 10)[0]["strong"]), 0.0, 0.001) d.boss_charge = 0.1 d.update([a, b], 50, false, false) assert_eq(spy.of_kind("joy", 10).size(), 1, "re-armed every 100 ms, not every frame") d.boss_charge = 0.5 d.update([a, b], 120, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2) assert_almost_eq(float(spy.of_kind("joy", 10)[1]["weak"]), 0.325, 0.001) d.update([a, b], 300, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2, "a frozen charge (dialogue freeze) does not keep buzzing")
func test_a_new_wind_up_starts_again_after_the_attack() -> void: var p := _pilot() d.update([p], 0, false, false) d.boss_charge = 0.9 d.update([p], 16, false, false) d.boss_charge = -1.0 d.update([p], 200, false, false) d.boss_charge = 0.0 d.update([p], 400, false, false) assert_eq(spy.of_kind("joy", 10).size(), 2)
func test_pausing_stops_rumble_and_holds_the_wind_up() -> void: var p := _pilot() d.update([p, _pilot()], 0, false, false) d.boss_charge = 0.2 d.update([p, _pilot()], 16, true, false) assert_eq(spy.of_kind("stop", 10).size(), 1) assert_eq(spy.of_kind("stop", 11).size(), 1) assert_eq(spy.of_kind("joy").size(), 0) d.update([p, _pilot()], 200, true, false) assert_eq(spy.of_kind("stop").size(), 2, "stops on the pause edge only")
func test_one_shots_still_fire_on_the_frame_a_pause_begins() -> void: var p := _pilot() d.update([p], 0, false, false) p.pending_levelups = 1 # the level-up that opened the panel d.update([p], 16, true, false) assert_eq(String(spy.calls[0]["kind"]), "stop", "the pause edge stops rumble first") assert_eq(String(spy.calls[spy.calls.size() - 1]["kind"]), "joy") d.update([p], 100, true, false) assert_eq(spy.of_kind("joy", 10).size(), 2, "the second tap still lands under the panel")
func test_disabled_sends_nothing_and_does_not_burst_on_reenable() -> void: var p := _pilot() d.update([p], 0, false, false) HapticsOutput.enabled = false p.hp = 50.0 p.pending_levelups = 1 d.update([p], 16, false, false) assert_eq(spy.calls.size(), 0) HapticsOutput.enabled = true d.update([p], 200, false, false) assert_eq(spy.calls.size(), 0, "state was tracked while off")
func test_muted_attract_mode_sends_nothing() -> void: var p := _pilot() d.update([p], 0, false, true) p.hp = 50.0 d.boss_charge = 0.5 d.note_events([{"kind": "reaction", "name": "BOSS DOWN"}]) d.update([p], 16, false, true) assert_eq(spy.calls.size(), 0)
func test_a_pilot_joining_is_a_baseline_not_a_hit() -> void: var a := _pilot() d.update([a], 0, false, false) var b := _pilot(40.0) b.pending_levelups = 1 d.update([a, b], 16, false, false) assert_eq(spy.calls.size(), 0)
func test_reset_stops_and_clears_baselines() -> void: var p := _pilot() d.update([p], 0, false, false) d.boss_charge = 0.5 d.reset() assert_eq(spy.of_kind("stop", 10).size(), 1) assert_eq(d.boss_charge, -1.0) var fresh := _pilot(10.0) d.update([fresh], 16, false, false) assert_eq(spy.of_kind("joy").size(), 0, "the new run's first frame is a baseline")- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_director.gd -gexit 2>&1 | tail -25
Expected: parse error — HapticsDirector not found.
- Step 3: Implement
input/haptics_director.gd
class_name HapticsDirectorextends RefCounted
# Gameplay haptics (spec docs/superpowers/specs/2026-10-05-haptics-design.md). Render-side ONLY:# main.gd calls note_events() + update() once per _process frame with the live pilots, and copies# the Warden's render_info "fire_charge" into boss_charge. Each pilot's state is diffed against the# previous frame (no new sim events), so /sim and the determinism baselines are untouched.## One-shot patterns fire even on the frame a pause begins: the sim is frozen while paused, so the# level-up that opened the panel and the hit that ended the run can only be seen on that frame.# A pause EDGE stops all rumble first; the continuous boss wind-up is held while paused, and only# re-arms while the charge is strictly rising (a dialogue freeze can't leave it buzzing).
# ── Patterns: every feel number in one place. Tune here (not remote tunables in v1). ──const HIT_SCALE := 4.0 # strength = damage / max_hp x HIT_SCALE ...const HIT_MIN := 0.25 # ... clamped to [HIT_MIN, 1.0]const HIT_MIN_MS := 40 # duration runs HIT_MIN_MS..HIT_MAX_MS with strengthconst HIT_MAX_MS := 120const HIT_STRONG_RATIO := 0.5 # the strong (low-frequency) motor = strength x thisconst HIT_COOLDOWN_MS := 150 # per pilot, so a contact swarm can't buzz continuouslyconst DOWN_MS := 400 # pilot down: both motors fullconst LEVEL_TAP_WEAK := 0.6const LEVEL_TAP_MS := 30 # level-up: two taps ...const LEVEL_TAP_GAP_MS := 80 # ... the second this long after the firstconst WIND_BASE := 0.15 # boss wind-up: weak motor = WIND_BASE + WIND_SPAN x chargeconst WIND_SPAN := 0.35const WIND_EVERY_MS := 100 # re-armed this often while the charge risesconst WIND_MS := 120 # a touch longer than the re-arm, so the rumble is continuousconst BOSS_KILL_MS := 300 # boss down: both motors full, every pilotconst ABILITY_MS := 18 # warp / decoy: matches TouchControls' own button tapconst WARP_WEAK := 0.7const DECOY_WEAK := 0.55const DASH_EDGE := 0.1 # main.gd's warp edge: dash_cd rises from <= 0 past thisconst DECOY_SPENT := 0.5 # decoy launch: drone_charge drops from >= 1.0 below this
var output: HapticsOutput = HapticsOutput.new()var boss_charge: float = -1.0 # BossWarden.render_info "fire_charge": -1 = not winding, else 0..1
var _hp: Array[float] = []var _levels: Array[int] = []var _dash: Array[float] = []var _charge: Array[float] = []var _hit_ok_ms: Array[int] = []var _queued: Array[Dictionary] = [] # {"at": ms, "pilot": index} — level-up second tapsvar _boss_kills := 0var _was_paused := falsevar _wind_charge := -1.0var _next_wind_ms := 0
# A new run (or back to the menu): stop everything and forget every baseline.func reset() -> void: output.stop_all() _hp.clear() _levels.clear() _dash.clear() _charge.clear() _hit_ok_ms.clear() _queued.clear() _boss_kills = 0 _was_paused = false boss_charge = -1.0 _wind_charge = -1.0 _next_wind_ms = 0
# main forwards the render_fx_events it drained this frame (it drains them before its children run).func note_events(events: Array) -> void: for ev: Variant in events: if not (ev is Dictionary): continue var e: Dictionary = ev if String(e.get("kind", "")) == "reaction" and String(e.get("name", "")) == "BOSS DOWN": _boss_kills += 1
# pilots: sim.pilots (Array of PlayerState). paused: the sim is frozen behind a menu/panel/death.# muted: attract mode or a capture run (state is tracked, nothing is sent).func update(pilots: Array, now_ms: int, paused: bool, muted: bool) -> void: var live := HapticsOutput.enabled and not muted if paused and not _was_paused: output.stop_all() _was_paused = paused for i in pilots.size(): var p: PlayerState = pilots[i] if i >= _hp.size(): # first frame of a run, or a pilot that just joined: baseline only _hp.append(p.hp) _levels.append(p.pending_levelups) _dash.append(p.dash_cd) _charge.append(p.drone_charge) _hit_ok_ms.append(0) continue if live: _pilot_events(i, p, now_ms) _hp[i] = p.hp _levels[i] = p.pending_levelups _dash[i] = p.dash_cd _charge[i] = p.drone_charge if live and _boss_kills > 0: for i in pilots.size(): output.pulse(i, 1.0, 1.0, BOSS_KILL_MS) _boss_kills = 0 _boss_wind(pilots.size(), now_ms, paused, live) _flush(now_ms, live)
func _pilot_events(i: int, p: PlayerState, now_ms: int) -> void: var was_hp := _hp[i] if was_hp > 0.0 and p.hp <= 0.0: output.pulse(i, 1.0, 1.0, DOWN_MS) elif was_hp > 0.0 and p.hp < was_hp and now_ms >= _hit_ok_ms[i]: var s := clampf((was_hp - p.hp) / maxf(p.max_hp, 1.0) * HIT_SCALE, HIT_MIN, 1.0) var ms := roundi(lerpf(float(HIT_MIN_MS), float(HIT_MAX_MS), (s - HIT_MIN) / (1.0 - HIT_MIN))) output.pulse(i, s, s * HIT_STRONG_RATIO, ms) _hit_ok_ms[i] = now_ms + HIT_COOLDOWN_MS if p.pending_levelups > _levels[i]: output.pulse(i, LEVEL_TAP_WEAK, 0.0, LEVEL_TAP_MS) _queued.append({"at": now_ms + LEVEL_TAP_GAP_MS, "pilot": i}) if _dash[i] <= 0.0 and p.dash_cd > DASH_EDGE: output.pulse(i, WARP_WEAK, 0.0, ABILITY_MS, false) # the touch WARP button buzzes itself if _charge[i] >= 1.0 and p.drone_charge < DECOY_SPENT: output.pulse(i, DECOY_WEAK, 0.0, ABILITY_MS, false) # likewise the DRONE button
func _boss_wind(pilot_count: int, now_ms: int, paused: bool, live: bool) -> void: if boss_charge < 0.0: _wind_charge = -1.0 return if paused or not live or boss_charge <= _wind_charge or now_ms < _next_wind_ms: return var s := WIND_BASE + WIND_SPAN * clampf(boss_charge, 0.0, 1.0) for i in pilot_count: output.pulse(i, s, 0.0, WIND_MS) _wind_charge = boss_charge _next_wind_ms = now_ms + WIND_EVERY_MS
func _flush(now_ms: int, live: bool) -> void: var i := _queued.size() - 1 while i >= 0: var q: Dictionary = _queued[i] if int(q["at"]) <= now_ms: if live: output.pulse(int(q["pilot"]), LEVEL_TAP_WEAK, 0.0, LEVEL_TAP_MS) _queued.remove_at(i) i -= 1- Step 4: Import, run the test
Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore Godot-rewritten files if listed)
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_director.gd -gexit 2>&1 | tail -25
Expected: all tests PASS.
- Step 5: Commit
git add input/haptics_director.gd tests/test_haptics_director.gdgit commit --file=<msg> # "feat(haptics): HapticsDirector frame-diff detectors + pattern table"Task 3: One persisted switch — meta.haptics, TouchControls, Debug Settings, Console
Section titled “Task 3: One persisted switch — meta.haptics, TouchControls, Debug Settings, Console”Files:
- Modify:
sim/meta_state.gd:57(field),:347(to_dict),:379(from_dict) - Modify:
meta/meta_store.gdafterset_share_diagnostics(ends ~line 206) - Modify:
ui/touch_controls.gd:61-63,:197-199 - Modify:
ui/debug_settings_panel.gd:99,:142,:750-754(touch_static),:787-793(set_touch_static) - Modify:
tests/test_debug_console_parity.gd:41-42,:57 - Modify:
tests/test_main.gd:573-583 - Modify:
tests/test_meta_state.gd(append aftertest_a_save_without_the_key_means_on, ~line 309) - Test:
tests/test_haptics_pref.gd
Interfaces:
-
Consumes:
HapticsOutput.enabled(Task 1). -
Produces:
MetaState.haptics: bool(defaulttrue, save key"haptics");static func MetaStore.set_haptics(m: MetaState, on: bool) -> void;DebugSettingsPanel.touch_static("haptics")/set_touch_static("haptics", v)read/writeHapticsOutput.enabled.TouchControls.hapticsis REMOVED. -
Step 1: Write the failing tests
Append to tests/test_meta_state.gd:
# ── Haptics switch (2026-10-05) ──────────────────────────────────────────────────────────
func test_haptics_defaults_on_and_round_trips() -> void: var m := MetaState.new() assert_true(m.haptics) m.haptics = false var back := MetaState.new() back.from_dict(m.to_dict()) assert_false(back.haptics)
func test_a_save_without_haptics_means_on() -> void: var d := MetaState.new().to_dict() d.erase("haptics") var back := MetaState.new() back.from_dict(d) assert_true(back.haptics)tests/test_haptics_pref.gd:
extends GutTest
# The ONE haptics switch (spec 2026-10-05-haptics-design.md "Toggle"): HapticsOutput.enabled at# runtime, MetaState.haptics on disk; the Debug Settings row and the Remote Console touch:haptics# key bind the same value. MetaStore disk IO is off for the whole GUT run (tests/gut_pre_run.gd).
var _was := truevar _compat := true
func before_each() -> void: _was = HapticsOutput.enabled _compat = MetaStore.last_load_compatible
func after_each() -> void: HapticsOutput.enabled = _was MetaStore.last_load_compatible = _compat
func test_set_haptics_writes_meta_and_saves() -> void: MetaStore.last_load_compatible = true var m := MetaState.new() var saves := MetaStore.save_count MetaStore.set_haptics(m, false) assert_false(m.haptics) assert_eq(MetaStore.save_count, saves + 1)
func test_set_haptics_never_writes_over_an_unreadable_save() -> void: MetaStore.last_load_compatible = false var m := MetaState.new() var saves := MetaStore.save_count MetaStore.set_haptics(m, false) assert_false(m.haptics, "the session still honours it") assert_eq(MetaStore.save_count, saves)
func test_debug_row_and_console_key_bind_the_one_switch() -> void: DebugSettingsPanel.set_touch_static("haptics", false) assert_false(HapticsOutput.enabled) assert_false(DebugSettingsPanel.touch_static("haptics")) DebugSettingsPanel.set_touch_static("haptics", true) assert_true(HapticsOutput.enabled) assert_true(DebugSettingsPanel.touch_static("haptics"))
func test_debug_row_is_labelled_haptics_and_keeps_its_console_key() -> void: var found := false for t: Array in DebugSettingsPanel.TOUCH_TOGGLES: if String(t[0]) == "haptics": found = true assert_eq(String(t[1]), "Haptics") assert_true(found, "the id stays 'haptics', so the console key stays touch:haptics") assert_true(DebugSettingsPanel.TOUCH_DESCS.has("haptics"))
func test_other_touch_flags_are_untouched() -> void: var slide := TouchControls.slide_abilities DebugSettingsPanel.set_touch_static("haptics", not HapticsOutput.enabled) assert_eq(TouchControls.slide_abilities, slide)- Step 2: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_pref.gd -gexit 2>&1 | tail -25
Expected: parse error (set_haptics not found).
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_meta_state.gd -gexit 2>&1 | tail -15
Expected: parse error / FAIL (haptics not a member of MetaState).
- Step 3: Implement the save field
sim/meta_state.gd:57 — after the share_diagnostics line add:
var haptics: bool = true # pause-menu "Haptics" toggle (2026-10-05); mirrors HapticsOutput.enabled. Never read by Sim.ticksim/meta_state.gd:347 — in to_dict(), change
"install_id": install_id, "share_diagnostics": share_diagnostics,
to
"install_id": install_id, "share_diagnostics": share_diagnostics, "haptics": haptics,
sim/meta_state.gd:379 — after
share_diagnostics = bool(d.get("share_diagnostics", true)) # older saves: on (matches past behaviour)
add:
haptics = bool(d.get("haptics", true)) # older saves: on (haptics shipped on by default)If SaveReset (progress-reset plan) exists on main by now, add haptics to the fields it keeps, next to share_diagnostics, and extend its test the same way it covers share_diagnostics.
meta/meta_store.gd — after the closing line of set_share_diagnostics ( save_state(m), ~line 206) add:
# The pause-menu "Haptics" toggle (2026-10-05). Same never-overwrite rule as set_share_diagnostics:# a save this build could not read keeps the choice in memory for the session only.static func set_haptics(m: MetaState, on: bool) -> void: m.haptics = on if last_load_compatible: save_state(m)- Step 4: Re-point TouchControls and the Debug row
ui/touch_controls.gd:61-63 — current:
# Player-facing behaviour switches (Debug Settings rows). Static so they survive run rebuilds.static var slide_abilities := falsestatic var haptics := truebecomes:
# Player-facing behaviour switches (Debug Settings rows). Static so they survive run rebuilds.# (Haptics moved to HapticsOutput.enabled, 2026-10-05: one switch for every buzz in the game.)static var slide_abilities := falseui/touch_controls.gd:197-199 — current:
func _haptic(ms: int, amp: float) -> void: if haptics and _pad != null and _pad.touch_active and not Platform.force_touch: Input.vibrate_handheld(ms, amp)becomes:
func _haptic(ms: int, amp: float) -> void: if HapticsOutput.enabled and _pad != null and _pad.touch_active and not Platform.force_touch: Input.vibrate_handheld(ms, amp)ui/debug_settings_panel.gd:99 — ["haptics", "Touch: haptics"], → ["haptics", "Haptics"],
ui/debug_settings_panel.gd:142 — "haptics": "Touch haptic feedback.", →
"haptics": "All haptics: controller rumble and phone vibration (hits, level-ups, bosses, warp/drone). Same switch as the pause-menu Haptics button; saved.",ui/debug_settings_panel.gd touch_static (ends return TouchControls.haptics, line 754) → last line becomes return HapticsOutput.enabled.
ui/debug_settings_panel.gd set_touch_static (ends TouchControls.haptics = v, line 793) → last line becomes HapticsOutput.enabled = v # main._sync_haptics_pref persists it.
- Step 5: Update the existing tests that named
TouchControls.haptics
tests/test_debug_console_parity.gd:41-42:
_touch = {"slide": TouchControls.slide_abilities, "haptics": HapticsOutput.enabled, "nudge": TouchControls.drone_nudge}tests/test_debug_console_parity.gd:57: HapticsOutput.enabled = bool(_touch["haptics"])
tests/test_main.gd:577-583 (inside test_dev_command_flags_are_validated_against_their_roster):
var haptics_before := HapticsOutput.enabled m.apply_dev_command({"kind": "touch_flag", "field": "not_a_flag", "on": not haptics_before}) assert_eq(HapticsOutput.enabled, haptics_before, "an unknown field must not fall through onto the last flag in the setter") m.apply_dev_command({"kind": "touch_flag", "field": "haptics", "on": not haptics_before}) assert_eq(HapticsOutput.enabled, not haptics_before) HapticsOutput.enabled = haptics_beforeVerify nothing else references the removed var: git grep -n "TouchControls.haptics" → must print nothing.
- Step 6: Run the tests
Run: godot --headless --path . --import > /dev/null 2>&1; git status --short (restore Godot-rewritten files)
Run each: -gselect=test_haptics_pref.gd, -gselect=test_meta_state.gd, -gselect=test_debug_console_parity.gd, -gselect=test_main.gd (each … -gexit 2>&1 | tail -15).
Expected: all PASS.
- Step 7: Commit
git add sim/meta_state.gd meta/meta_store.gd ui/touch_controls.gd ui/debug_settings_panel.gd \ tests/test_haptics_pref.gd tests/test_meta_state.gd tests/test_debug_console_parity.gd tests/test_main.gdgit commit --file=<msg> # "feat(haptics): one persisted switch (meta.haptics) for touch taps, Debug row and console"Task 4: Pause-menu “Haptics On / Off” toggle
Section titled “Task 4: Pause-menu “Haptics On / Off” toggle”Files:
- Modify:
ui/pause_menu.gd:22(signal),:29-31(vars),:167-195(rows),:289-300(_pair_step),:302-310(labels/toggle) - Modify:
tests/test_pause_menu.gd:44,:279-293 - Test:
tests/test_haptics_toggle.gd - Modify (after eyeball):
tools/ui_sweep/baseline/pause.png
Interfaces:
-
Consumes: nothing from earlier tasks (main wires it in Task 5).
-
Produces:
signal haptics_toggled(on: bool);var haptics_on := true(set beforeadd_child);var _haptics_btn: Button;static func haptics_label(on: bool) -> String(“Haptics On” / “Haptics Off”);func _half_row(a: Button, b: Button) -> HBoxContainer;var _pairs: Array(each[left: Button, right: Button]). -
Step 1: Write the failing tests
tests/test_haptics_toggle.gd:
extends GutTest
# Pause-menu "Haptics On/Off" (spec 2026-10-05-haptics-design.md "Toggle"). It shares a half-row# with Bestiary: a full-width row pushes the glass column past its 660 px budget (it measured 646# before this button).
var _glass_was := true
func before_each() -> void: _glass_was = VisualLook.glass_ui
func after_each() -> void: VisualLook.glass_ui = _glass_was await get_tree().process_frame
func _menu(haptics_on := true, hidden: Array[String] = []) -> PauseMenu: var p := PauseMenu.new() p.haptics_on = haptics_on p.hidden_entries = hidden add_child_autofree(p) await get_tree().process_frame return p
func _bestiary(p: PauseMenu) -> Button: for b in p._buttons: if b.text == "Bestiary": return b return null
func test_toggle_flips_label_and_emits() -> void: var p := await _menu(true) assert_eq(p._haptics_btn.text, "Haptics On") assert_true(p._buttons.has(p._haptics_btn), "reachable by up/down nav") watch_signals(p) p._haptics_btn.emit_signal("pressed") assert_signal_emitted_with_parameters(p, "haptics_toggled", [false]) assert_eq(p._haptics_btn.text, "Haptics Off") p._haptics_btn.emit_signal("pressed") assert_signal_emitted_with_parameters(p, "haptics_toggled", [true])
func test_starts_from_the_saved_state() -> void: var p := await _menu(false) assert_eq(p._haptics_btn.text, "Haptics Off")
func test_haptics_follows_bestiary_and_shares_its_row() -> void: var p := await _menu() var best := _bestiary(p) assert_not_null(best) assert_eq(p._buttons.find(p._haptics_btn), p._buttons.find(best) + 1, "nav order = visual order") assert_true(p._haptics_btn.get_parent() is HBoxContainer) assert_eq(p._haptics_btn.get_parent(), best.get_parent(), "one half-row")
func test_left_right_steps_within_the_bestiary_haptics_row() -> void: var p := await _menu() var best := _bestiary(p) p._sel = p._buttons.find(best) assert_true(p._pair_step(1)) assert_eq(p._sel, p._buttons.find(p._haptics_btn)) assert_true(p._pair_step(-1)) assert_eq(p._sel, p._buttons.find(best))
func test_feedback_diagnostics_row_still_steps() -> void: var p := await _menu() p._sel = p._buttons.find(p._fb_btn) assert_true(p._pair_step(1)) assert_eq(p._sel, p._buttons.find(p._diag_btn))
func test_left_right_on_a_full_row_is_not_handled() -> void: var p := await _menu() p._sel = 0 # Resume assert_false(p._pair_step(1))
func test_haptics_stays_when_bestiary_is_hidden() -> void: var hidden: Array[String] = ["bestiary"] var p := await _menu(true, hidden) assert_true(p._buttons.has(p._haptics_btn)) assert_true(p._haptics_btn.is_inside_tree()) assert_false(p._haptics_btn.get_parent() is HBoxContainer, "full width on its own")
func test_glass_bestiary_haptics_row_fits_the_column() -> void: VisualLook.glass_ui = true var p := await _menu() var row := p._haptics_btn.get_parent() assert_lte((row as Control).get_combined_minimum_size().x, 380.0, "labels don't widen the row")In tests/test_pause_menu.gd:44 change the count line to:
assert_eq(p._buttons.size(), 10 if not BuildConfig.dev_tools() else 12, "build label did not get counted into the nav button list (Diagnostics + Haptics toggles add one each)")In tests/test_pause_menu.gd test_legacy_column_sizes_are_unchanged (line 279), replace the loop body’s pair check:
for b in menu._buttons: if b == menu._fb_btn or b == menu._diag_btn or b == menu._haptics_btn or b.text == "Bestiary": # Two half-rows (telemetry v2 visual QA D4; Haptics 2026-10-05): Send feedback | Diagnostics # and Bestiary | Haptics. Same height, half the 440 column each, so the column is no taller. assert_eq(b.custom_minimum_size, Vector2((440.0 - 16.0) / 2.0, 56), b.text) continue assert_eq(b.custom_minimum_size, Vector2(440, 56), b.text)- Step 2: Run to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_toggle.gd -gexit 2>&1 | tail -25
Expected: parse error / FAIL (haptics_on, _haptics_btn not members).
- Step 3: Implement
ui/pause_menu.gd:22 — after signal diagnostics_toggled(on: bool) # … add:
signal haptics_toggled(on: bool) # the "Haptics" toggle (2026-10-05); main persists itui/pause_menu.gd:29-31 — after var _fb_btn: Button … add:
var haptics_on := true # set before add_child: HapticsOutput.enabled, the state the toggle starts fromvar _haptics_btn: Button # "Haptics On/Off", paired with Bestiary on one half-rowvar _pairs: Array = [] # [left: Button, right: Button] half-rows; left/right steps within one (_pair_step)ui/pause_menu.gd:167-168 — current:
if not hidden_entries.has("bestiary"): box.add_child(_make_btn("Bestiary", func() -> void: bestiary_requested.emit(), &"accent.soft"))becomes:
# Haptics toggle (2026-10-05). Made first and re-appended after Bestiary, like _diag_btn below, so # up/down nav stays in visual order. It shares Bestiary's row: a full-width row pushed the glass # column past 720p with the dev rows (646 of the 660 px budget before it). Alone while Bestiary # is hidden (tutorial), where the column is shorter anyway. _haptics_btn = _make_btn(haptics_label(haptics_on), func() -> void: _toggle_haptics(), &"blue.soft") _buttons.erase(_haptics_btn) if not hidden_entries.has("bestiary"): var best := _make_btn("Bestiary", func() -> void: bestiary_requested.emit(), &"accent.soft") _buttons.append(_haptics_btn) box.add_child(_half_row(best, _haptics_btn)) else: _buttons.append(_haptics_btn) box.add_child(_haptics_btn)ui/pause_menu.gd:177-193 — current block (inside if not hidden_entries.has("feedback"):):
# Paired on one row in BOTH looks: a full-width Diagnostics row would push the glass column # past 720p with the dev rows (test_glass_column_with_dev_rows_fits_720p) and "Back to Start # Menu" off an iPhone in the legacy look. Left/right move within the pair (_pair_step). The # buttons expand to fill the row, so it tracks a column widened by the dev rows. var sep := GLASS_SEPARATION if _glass else 16 var col_w := 380.0 if _glass else 440.0 var pair := HBoxContainer.new() pair.alignment = BoxContainer.ALIGNMENT_CENTER pair.add_theme_constant_override("separation", sep) for b: Button in [fb, _diag_btn]: b.custom_minimum_size = Vector2((col_w - sep) / 2.0, b.custom_minimum_size.y) b.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Registered after _make_btn's font.xl binding, so it re-applies after it on a token change. TokenBind.theme_font_size(b, &"font_size", GLASS_DEV_BTN_FONT) pair.add_child(b) box.add_child(pair)becomes:
# Paired on one row in BOTH looks: a full-width Diagnostics row would push the glass column # past 720p with the dev rows (test_glass_column_with_dev_rows_fits_720p) and "Back to Start # Menu" off an iPhone in the legacy look. box.add_child(_half_row(fb, _diag_btn))Add the helper next to _make_btn (before func _make_btn, ~line 500):
# One row of two half-width buttons (Send feedback | Diagnostics, Bestiary | Haptics). Left/right# step within it (_pair_step). The buttons expand to fill the row, so it tracks a column widened# by the dev rows.func _half_row(a: Button, b: Button) -> HBoxContainer: var sep := GLASS_SEPARATION if _glass else 16 var col_w := 380.0 if _glass else 440.0 var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", sep) for btn: Button in [a, b]: btn.custom_minimum_size = Vector2((col_w - sep) / 2.0, btn.custom_minimum_size.y) btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Registered after _make_btn's font.xl binding, so it re-applies after it on a token change. TokenBind.theme_font_size(btn, &"font_size", GLASS_DEV_BTN_FONT) row.add_child(btn) _pairs.append([a, b]) return rowui/pause_menu.gd:287-300 — replace _pair_step (current body checks only _fb_btn / _diag_btn) with:
# Left/right inside a half-row (Send feedback | Diagnostics, Bestiary | Haptics; tvOS: the Siri# Remote swipes sideways). Returns true when it handled the event; every other row keeps swallowing# left/right as before.func _pair_step(dir: int) -> bool: var cur := _buttons[_sel] if _sel >= 0 and _sel < _buttons.size() else null for pair: Array in _pairs: var left: Button = pair[0] var right: Button = pair[1] if cur != left and cur != right: continue var target := right if dir > 0 else left if target != cur and not target.disabled: _sel = _buttons.find(target) _ui_nav() _focus_sel() return true return falseui/pause_menu.gd:310 — after _toggle_diagnostics() add:
static func haptics_label(on: bool) -> String: return "Haptics On" if on else "Haptics Off"
func _toggle_haptics() -> void: haptics_on = not haptics_on _haptics_btn.text = haptics_label(haptics_on) haptics_toggled.emit(haptics_on)- Step 4: Run the tests
Run each with … -gexit 2>&1 | tail -15: -gselect=test_haptics_toggle.gd, -gselect=test_pause_menu.gd, -gselect=test_diagnostics_toggle.gd.
Expected: all PASS — in particular test_glass_column_with_dev_rows_fits_720p still ≤ 660 and test_glass_feedback_and_diagnostics_share_one_row_that_fits unchanged.
- Step 5: UI sweep + eyeball
Run (windowed, foreground, one call, big timeout): zsh scripts/ui-sweep.sh
Expected: pause.png (and probably pause_stats.png) over threshold; everything else as on unmodified main (bestiary.png / hud_in_run.png are known-flaky on main, see docs/reference/running-and-testing.md).
Open tools/ui_sweep/out/pause.png with the Read tool: Bestiary and Haptics On side by side, labels fully visible, column inside the frame. If right, bless only the changed pause captures:
cp tools/ui_sweep/out/pause.png tools/ui_sweep/baseline/pause.png (and pause_stats.png if it changed for the same reason).
Then git status --short; git checkout -- project.godot audio/bus_layout.tres if listed.
- Step 6: Commit
git add ui/pause_menu.gd tests/test_haptics_toggle.gd tests/test_pause_menu.gd tools/ui_sweep/baseline/pause*.pnggit commit --file=<msg> # "feat(haptics): pause-menu Haptics On/Off toggle, paired with Bestiary"Task 5: Wire it into main
Section titled “Task 5: Wire it into main”Files:
- Modify:
main.gd— member vars (nearvar quality_manager, line 120),_readyboot (after line 326),_toggle_pause(lines 561, 580),_return_to_menu(after line 732),_new_run(after line 1503),_process(after 1831 and after 1872), boss charge (after 2111),_on_diagnostics_toggledneighbour (~line 1820) - Test:
tests/test_haptics_main.gd
Interfaces:
-
Consumes:
HapticsOutput(Task 1),HapticsDirector(Task 2),MetaStore.set_haptics/meta.haptics(Task 3),PauseMenu.haptics_on/haptics_toggled(Task 4),HapticsSpy(tests). -
Produces (main.gd):
var haptics: HapticsDirector;var haptic_routes_override: Callable(test seam:func(pilot_count: int) -> Array);func _drive_haptics(render_events: Array) -> void;func _haptics_paused() -> bool;func _sync_haptics_pref() -> void;func _on_haptics_toggled(on: bool) -> void. -
Step 1: Write the failing tests
tests/test_haptics_main.gd:
extends GutTest
# main.gd wiring for gameplay haptics (spec 2026-10-05-haptics-design.md). Input's vibration calls# are no-ops headless and there are no joypads, so each test swaps in a spy output and a fake# route list (main.haptic_routes_override) giving P1 the pad device 7.
var _was := true
func before_each() -> void: _was = HapticsOutput.enabled
func after_each() -> void: HapticsOutput.enabled = _was
func _boot() -> Node: var m: Node = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame return m
func _run(m: Node) -> HapticsSpy: m._on_mode_chosen("survival") m._auto = false # a human is playing (attract mode is muted) var spy := HapticsSpy.new() m.haptics.output = spy m.haptic_routes_override = func(n: int) -> Array: return HapticsOutput.routes_for(n, [7], false) m._drive_haptics([]) # first frame: routes + baselines return spy
func test_boot_loads_the_switch_from_meta() -> void: HapticsOutput.enabled = false var m := await _boot() assert_true(m.meta.haptics, "fresh save: on") assert_true(HapticsOutput.enabled, "boot copied meta.haptics over a stale static")
func test_a_hit_on_p1_rumbles_its_pad() -> void: var m := await _boot() var spy := _run(m) m.sim.player.hp -= 30.0 m._drive_haptics([]) assert_eq(spy.of_kind("joy", 7).size(), 1)
func test_attract_mode_is_silent() -> void: var m := await _boot() var spy := _run(m) m._auto = true m.sim.player.hp -= 30.0 m._drive_haptics([]) assert_eq(spy.of_kind("joy").size(), 0)
func test_process_forwards_boss_down_from_render_events() -> void: var m := await _boot() var spy := _run(m) m.sim.render_fx_events.append({"kind": "reaction", "pos": Vector2.ZERO, "element": -1, "name": "BOSS DOWN"}) await get_tree().process_frame # main._process drains render_fx_events and forwards them var long := spy.of_kind("joy", 7).filter(func(c: Dictionary) -> bool: return int(c["ms"]) == HapticsDirector.BOSS_KILL_MS) assert_eq(long.size(), 1)
func test_pause_toggle_persists_and_stops_rumble() -> void: var m := await _boot() var spy := _run(m) m._toggle_pause() await get_tree().process_frame assert_not_null(m.pause_menu, "sanity: paused") assert_eq(m.pause_menu._haptics_btn.text, "Haptics On", "starts from HapticsOutput.enabled") spy.calls.clear() m.pause_menu._haptics_btn.emit_signal("pressed") assert_false(HapticsOutput.enabled) assert_false(m.meta.haptics, "persisted to meta") assert_eq(spy.of_kind("stop", 7).size(), 1, "stopped at once")
func test_console_flip_is_persisted_by_main() -> void: var m := await _boot() m.apply_dev_command({"kind": "touch_flag", "field": "haptics", "on": false}) await get_tree().process_frame assert_false(m.meta.haptics, "main._sync_haptics_pref mirrors the static into the save")
func test_pausing_counts_as_paused_for_haptics() -> void: var m := await _boot() _run(m) assert_false(m._haptics_paused()) m._paused_for_menu = true assert_true(m._haptics_paused()) m._paused_for_menu = false m._paused_for_levelup = true assert_true(m._haptics_paused()) m._paused_for_levelup = false
func test_a_new_run_resets_the_director() -> void: var m := await _boot() var spy := _run(m) spy.calls.clear() m._on_mode_chosen("survival") assert_eq(spy.of_kind("stop", 7).size(), 1, "the old run's rumble is stopped") assert_eq(m.haptics.boss_charge, -1.0)- Step 2: Run to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_main.gd -gexit 2>&1 | tail -25
Expected: FAIL (haptics / _drive_haptics not members of main).
- Step 3: Implement in main.gd
Member vars — after var quality_manager: QualityManager # adaptive graphics (persistent across runs) (line 120):
var haptics: HapticsDirector # gameplay rumble / vibration (render-side; persistent across runs)# Test seam: func(pilot_count: int) -> Array of HapticsOutput routes. Empty = the real devices.var haptic_routes_override := Callable()Boot — after MetaStore.apply_diag_off_marker(meta) # a Diagnostics opt-out outlives an unreadable save (line 326):
HapticsOutput.enabled = meta.haptics # the one haptics switch (pause menu / Debug Settings / console) haptics = HapticsDirector.new()_toggle_pause — after pause_menu.share_diagnostics = meta.share_diagnostics if meta != null else true (line 561):
pause_menu.haptics_on = HapticsOutput.enabled # before add_child (builds the toggle)and after pause_menu.diagnostics_toggled.connect(_on_diagnostics_toggled) (line 580):
pause_menu.haptics_toggled.connect(_on_haptics_toggled)_return_to_menu — after _close_pause() (line 732):
if haptics != null: haptics.reset() # no rumble carries onto the start menu_new_run — after _last_dash_cd = sim.player.dash_cd (line 1503):
if haptics != null: haptics.reset() # new pilots: fresh baselines, nothing still rumbling from the last runNew functions — after _on_diagnostics_toggled (ends SentryBoot.apply(on, id) # …, ~line 1820):
# Pause-menu "Haptics" toggle (2026-10-05).func _on_haptics_toggled(on: bool) -> void: HapticsOutput.enabled = on _sync_haptics_pref()
# HapticsOutput.enabled is the runtime truth; the pause menu, the Debug Settings "Haptics" row and# the console touch:haptics key all write it. Mirror any change into the save and stop rumble the# moment it goes off. Runs every frame (a bool compare), including on the start menu.func _sync_haptics_pref() -> void: if meta == null or meta.haptics == HapticsOutput.enabled: return MetaStore.set_haptics(meta, HapticsOutput.enabled) if not HapticsOutput.enabled and haptics != null: haptics.output.stop_all()
# The sim is frozen behind something (mirrors _physics_process's _frozen, minus the dialogue freeze# the director handles by the wind-up only re-arming on a rising charge).func _haptics_paused() -> bool: return sim.game_over or _paused_for_menu or _paused_for_levelup or _paused_for_dimension_choice \ or _story_won or _warping or (codex != null and codex.is_open())
# Once per _process frame, right after render_fx_events is drained (before the game-over return,# so the killing hit and BOSS DOWN still buzz). Routes are rebuilt every frame from the same device# mapping input uses (solo: every playable pad; co-op: pilot k = playable[k]) — never cached.func _drive_haptics(render_events: Array) -> void: if haptics == null or sim == null: return var n := sim.pilots.size() var routes: Array = [] if haptic_routes_override.is_valid(): routes = haptic_routes_override.call(n) as Array else: routes = HapticsOutput.routes_for(n, input_router.playable_joypads(), Platform.is_touch() and not Platform.force_touch) haptics.output.routes = routes haptics.note_events(render_events) haptics.update(sim.pilots, Time.get_ticks_msec(), _haptics_paused(), _auto or _capturing)_process — after _update_save_debug_label() # TEMP: … (line 1831):
_sync_haptics_pref() # before the sim == null return: a console flip on the menu is saved too_process — after
if perf_scenario != null and is_instance_valid(perf_scenario): perf_scenario.note_events(render_events)(line 1871-1872) add:
_drive_haptics(render_events)Boss charge — after var binfo := sim.boss_warden.render_info(sim) (line 2111):
if haptics != null: haptics.boss_charge = float(binfo.get("fire_charge", -1.0)) # Warden barrage wind-up, -1 = none- Step 4: Run tests, boot check, full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_haptics_main.gd -gexit 2>&1 | tail -25 → all PASS.
Run: godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR" → empty.
Run: bash scripts/check-test-count.sh → count matches, 0 failures (on failure: grep -n "FAILED\|Failing" $TMPDIR/bh-gut-last.log | head -40).
Then git status --short; restore project.godot / audio/bus_layout.tres if listed.
- Step 5: Commit
git add main.gd tests/test_haptics_main.gdgit commit --file=<msg> # "feat(haptics): main drives HapticsDirector per frame, persists the switch, pause toggle"Task 6: Docs, What’s new, feel-check checklist, gate
Section titled “Task 6: Docs, What’s new, feel-check checklist, gate”Files:
- Modify:
docs/architecture/coop-and-pilots.md(new## Haptics (2026-10-05)section at the end) - Modify:
docs/architecture/dev-tools.md(Remote Playtest Console section: one line on the renamed row) - Modify:
control/src/whats_new.js(thebuild: nullentry at the top)
Interfaces:
-
Consumes: everything above (names as defined).
-
Produces: docs + What’s new item; nothing code-facing.
-
Step 1: Architecture doc
Append to docs/architecture/coop-and-pilots.md:
## Haptics (2026-10-05)
Spec `docs/superpowers/specs/2026-10-05-haptics-design.md`, plan `docs/superpowers/plans/2026-10-05-haptics.md`.Render-side only; `/sim` is untouched.
- `input/haptics_director.gd` (`HapticsDirector`, RefCounted owned by `main`): diffs each pilot's `hp`, `pending_levelups`, `dash_cd` and `drone_charge` frame to frame and fires the pattern table (all feel numbers are the consts at the top of the file). Boss kill comes from the `"BOSS DOWN"` reactions main forwards out of `render_fx_events`; boss wind-up from `BossWarden.render_info` `fire_charge`, which main copies onto `haptics.boss_charge`.- `input/haptics_output.gd` (`HapticsOutput`): pilot index -> `{"joypads": [...], "handheld": bool}`. `routes_for()` mirrors input: a solo pilot rumbles EVERY playable pad (`InputRouter.poll()` scans them all); co-op pilot k rumbles `playable[k]` (`poll_device`). P1 gets the phone/tablet vibrator only on a touch device with no pad. Rebuilt every frame, so pilots 3-4 need no change here. Warp/decoy pulses skip the handheld (the touch buttons buzz themselves).- One switch: `HapticsOutput.enabled`. Written by the pause-menu Haptics button, the Debug Settings "Haptics" row and the console `touch:haptics` key; `main._sync_haptics_pref()` persists it to `meta.haptics` and stops rumble when it goes off. `TouchControls._haptic` reads it too.- Pausing: a pause edge stops all rumble; one-shots still fire on that frame (the level-up that opened the panel, the hit that ended the run); the wind-up only re-arms while the charge rises. Attract mode / capture runs are muted.- Tests use `tests/haptics_spy.gd` (`HapticsSpy`): `Input` vibration calls are silent no-ops headless.
### On-device feel check (owed, Chris)
- [ ] iPhone, touch only: a hit buzzes (bigger hit = longer), level-up is a double tap, WARP/DRONE taps buzz once (not twice), Warden barrage wind-up rumbles faintly then BOSS DOWN thumps. Pause menu: Haptics Off silences all of it, including the WARP/DRONE taps; survives relaunch.- [ ] Apple TV + MFi pad: same events on the pad; the Siri Remote never buzzes (no motor); pausing mid wind-up stops the rumble at once.- [ ] Mac + pad (SDL): same events; keyboard-only play does nothing and logs no errors.- [ ] Contact swarm / burn: the 150 ms hit cooldown does not read as a continuous buzz.- [ ] Co-op (2 pads): each pilot's hits buzz only their own pad; BOSS DOWN buzzes both.- [ ] OPEN: Android pad rumble is unverified (engine path not checked on device).- Step 2: dev-tools doc
In docs/architecture/dev-tools.md, in the ## Remote Playtest Console section, add one bullet next to the Debug Settings parity text:
- The touch toggles roster's `haptics` row is labelled **"Haptics"** (2026-10-05) and drives ALL haptics (`HapticsOutput.enabled`, persisted as `meta.haptics` by main); its console key stays `touch:haptics`.- Step 3: What’s new
In control/src/whats_new.js, if the first entry is already build: null, date: null,, append this item to its items; otherwise insert this entry as the first element of WHATS_NEW (first line exactly build: null, date: null,):
{ build: null, date: null, title: 'Haptics: controller rumble and phone vibration for hits, level-ups and bosses, with an on/off switch', items: [ { text: 'Gameplay haptics on every device: a hit buzzes (harder hits buzz longer), losing a ship is a long thump, a level-up is a double tap, the Warden barrage wind-up rumbles faintly and a boss kill thumps every pilot. Controllers rumble; a phone with no controller vibrates. Warp and Drone buzz on controllers too. In co-op each pilot only feels their own pad. New Haptics On/Off button on the pause menu (next to Bestiary) turns all of it off, including the touch button taps, and is remembered. The Debug Settings row is now just "Haptics" and this console\'s touch:haptics switch flips the same setting.', where: 'Pause menu: Haptics On/Off; play a run with a controller and with touch only' }, ], },Run: node --test control/test/whats-new.test.mjs → PASS.
- Step 4: Gate
Run: bash scripts/gate.sh → exit 0 (import, full suite + count guard incl. all three determinism files, tutorial trace lessons: ok, boot smoke).
Run: node --test scripts/test/*.test.mjs → PASS (docs-site frontmatter test covers the edited docs).
Then git status --short; restore project.godot / audio/bus_layout.tres if listed.
- Step 5: Commit
git add docs/architecture/coop-and-pilots.md docs/architecture/dev-tools.md control/src/whats_new.jsgit commit --file=<msg> # "docs(haptics): architecture, What's new, on-device feel-check list"After merge to main: redeploy the console Worker (control/ changed) per docs/architecture/dev-tools.md, and hand Chris the feel-check list above with the next Dev install.
Self-review
Section titled “Self-review”- Spec coverage: render-side director (Task 2, wired Task 5); every table row — hit + cooldown, down, level-up double tap, boss wind-up, boss kill, warp/decoy (Task 2 tests);
note_eventsforwarding like telemetry (Task 5_drive_hapticsafter the drain); one const block (Task 2);HapticsOutputjoypad / touch / none routing + injectable spy (Task 1); per-pilot device list from main, any pilot count (Task 1routes_for, Task 5); TouchControls re-pointed to the one flag (Task 3);meta.hapticsdefault true, saved likeshare_diagnostics(Task 3); pause button +haptics_toggled+ main persists (Tasks 4-5); Debug row renamed, console key kept, parity green (Task 3); off stops all pads (Tasks 1, 5); disconnect safety via per-frame routes (Task 5); pause skip + stop on pause (Task 2, Task 5_haptics_paused); reset on_new_run(Task 5); tests listed in the spec (Tasks 1-5); on-device feel check + Android open item, What’s newbuild: null(Task 6); no privacy change (Global Constraints). - Placeholders: none; commit messages are given inline as the one-line summary to put in the
--filemessage. - Type consistency:
HapticsOutput.enabled/routes/routes_for/pulse/stop_all/_joy/_handheld/_joy_stop,HapticsSpy.calls/of_kind,HapticsDirector.output/boss_charge/note_events/update/reset/BOSS_KILL_MS,MetaState.haptics,MetaStore.set_haptics,PauseMenu.haptics_on/_haptics_btn/haptics_toggled/haptics_label/_half_row/_pairs/_pair_step,main.haptics/haptic_routes_override/_drive_haptics/_haptics_paused/_sync_haptics_pref/_on_haptics_toggledare used with the same names and signatures in every task.
Execution handoff
Section titled “Execution handoff”- Written 2026-10-05 by a planning subagent in worktree
.claude/worktrees/coop-haptics-specs(branchcoop-haptics-specs); the parent session commits the plan. Nothing in the plan has been executed; no device installs, deploys or pushes were made or authorized by writing it. - Planning ran one
godot --headless --path . --importin the worktree (it created the gitignored.godot/cache and rewroteaudio/bus_layout.tres, which was restored) and a throwaway GUT probe (deleted) that measured the glass pause column at 646 / 660 px. That measurement is why Haptics shares Bestiary’s row (deviation 9 above). - Chris’s call still owed: eyeball
tools/ui_sweep/out/pause.png(Task 4 Step 5) if the implementer is unsure about the Bestiary | Haptics pairing, and the on-device feel check (Task 6). - A sibling plan (local co-op up to 4 pilots) shares this branch’s spec commit;
HapticsOutput.routes_foralready handles any pilot count, so the order between the two plans does not matter. - Next action: execute Task 1 (consult
bh-sdd-concurrentfirst).