Skip to content

Gameplay haptics (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, consult bh-sdd-concurrent before Task 1 and dispatch implementers into their own worktree (.claude/worktrees/<name>, branch off origin/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

  • Render-side only: no file under sim/ changes except sim/meta_state.gd (the save model, which lives there and is “never read by Sim.tick”); the seed-1234 baselines in tests/test_determinism_survival.gd, tests/test_determinism.gd, tests/test_determinism_coop.gd must 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_levelups increased): two 30 ms taps, the second 80 ms later.
  • Boss wind-up (fire_charge rising): weak rumble, strength = 0.15 + 0.35 × charge, re-armed every 100 ms, all pilots.
  • Boss kill ("BOSS DOWN" reaction in render_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.haptics defaults to true; 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.gd stays 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 as Variant (or an explicit type), never inferred :=; tabs for indentation.
  • After creating any new class_name file run godot --headless --path . --import before a headless run.
  • Never pull a raw GUT run into context: use bash scripts/check-test-count.sh and 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; restore project.godot and audio/bus_layout.tres with git checkout -- if Godot rewrote them. Never commit them.
  • Every new docs/**/*.md needs Starlight title: 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)”
  1. Sim.boss_render_info() does not exist. The wind-up charge is sim.boss_warden.render_info(sim)["fire_charge"] (sim/boss_warden.gd:524-541): -1.0 when not winding the barrage, else 0..1. main._process already computes it as binfo at main.gd:2111; main copies it onto haptics.boss_charge there.
  2. 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 call poll_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.
  3. 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_touch AND no playable pad is connected (a pad, when present, is the input device and gets the rumble).
  4. Warp/decoy taps on touch would double-buzz. TouchControls.fire_dash/fire_decoy already call _haptic(18, …) on the press. The director’s warp/decoy pulses pass handheld_ok = false, so they only reach pads.
  5. 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 from PlayerState.drone_charge dropping from ≥ 1.0 to < 0.5 (DroneDirector.update_drones sets it to 0 on deploy, sim/drone_director.gd:113-115), and the warp from dash_cd rising from ≤ 0 past 0.1 (main’s own rule, main.gd:1622). Works for any pilot count.
  6. “Skip while paused” would drop the level-up buzz. The level-up panel opens (_paused_for_levelup = true) in the same frame pending_levelups rises, and death sets game_over in 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.
  7. 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 _capturing mutes output (state is still tracked, so nothing bursts when a human takes over).
  8. Director is a RefCounted, not a Node. main._new_run and _return_to_menu sweep get_children(); a RefCounted owned by main survives that without special-casing, and main drives it explicitly (it needs render_events main has already drained), so a Node buys nothing.
  9. 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 eyeballs pause.png before it is blessed.
  10. Debug/Console writes bypass main. DebugSettingsPanel.set_touch_static is static and has no meta. Resolved: every writer flips HapticsOutput.enabled; main._sync_haptics_pref() (top of _process, before the sim == null return) persists any difference to meta.haptics and stops rumble when it went off.
  11. 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, …). If SaveReset exists on main when this executes, add haptics to its keep-list in Task 3 (a device preference, not progress).
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 (default true) — THE haptics switch.
    • var routes: Array — one Dictionary per pilot index: {"joypads": Array (int device ids), "handheld": bool}.
    • static func routes_for(pilot_count: int, playable: Array, touch: bool) -> Array
    • func pulse(pilot: int, weak: float, strong: float, ms: int, handheld_ok: bool = true) -> void (no-op while enabled is 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: Array of {"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 HapticsSpy
extends 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 out

tests/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 HapticsOutput
extends 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
Terminal window
git add input/haptics_output.gd tests/haptics_spy.gd tests/test_haptics_output.gd
git 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), PlayerState fields hp, max_hp, pending_levelups, dash_cd, drone_charge (sim/player_state.gd:5-6,17,25,41).

  • Produces:

    • var output: HapticsOutput (replaceable; default HapticsOutput.new())
    • var boss_charge: float (-1 = not winding; main writes it)
    • func note_events(events: Array) -> void
    • func update(pilots: Array, now_ms: int, paused: bool, muted: bool) -> void
    • func 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 := true
var spy: HapticsSpy
var 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 HapticsDirector
extends 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 strength
const HIT_MAX_MS := 120
const HIT_STRONG_RATIO := 0.5 # the strong (low-frequency) motor = strength x this
const HIT_COOLDOWN_MS := 150 # per pilot, so a contact swarm can't buzz continuously
const DOWN_MS := 400 # pilot down: both motors full
const LEVEL_TAP_WEAK := 0.6
const LEVEL_TAP_MS := 30 # level-up: two taps ...
const LEVEL_TAP_GAP_MS := 80 # ... the second this long after the first
const WIND_BASE := 0.15 # boss wind-up: weak motor = WIND_BASE + WIND_SPAN x charge
const WIND_SPAN := 0.35
const WIND_EVERY_MS := 100 # re-armed this often while the charge rises
const WIND_MS := 120 # a touch longer than the re-arm, so the rumble is continuous
const BOSS_KILL_MS := 300 # boss down: both motors full, every pilot
const ABILITY_MS := 18 # warp / decoy: matches TouchControls' own button tap
const WARP_WEAK := 0.7
const DECOY_WEAK := 0.55
const DASH_EDGE := 0.1 # main.gd's warp edge: dash_cd rises from <= 0 past this
const 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 taps
var _boss_kills := 0
var _was_paused := false
var _wind_charge := -1.0
var _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
Terminal window
git add input/haptics_director.gd tests/test_haptics_director.gd
git 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.gd after set_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 after test_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 (default true, save key "haptics"); static func MetaStore.set_haptics(m: MetaState, on: bool) -> void; DebugSettingsPanel.touch_static("haptics") / set_touch_static("haptics", v) read/write HapticsOutput.enabled. TouchControls.haptics is 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 := true
var _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.tick

sim/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 := false
static var haptics := true

becomes:

# 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 := false

ui/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_before

Verify 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
Terminal window
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.gd
git 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 before add_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 it

ui/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 from
var _haptics_btn: Button # "Haptics On/Off", paired with Bestiary on one half-row
var _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 row

ui/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 false

ui/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
Terminal window
git add ui/pause_menu.gd tests/test_haptics_toggle.gd tests/test_pause_menu.gd tools/ui_sweep/baseline/pause*.png
git commit --file=<msg> # "feat(haptics): pause-menu Haptics On/Off toggle, paired with Bestiary"

Files:

  • Modify: main.gd — member vars (near var quality_manager, line 120), _ready boot (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_toggled neighbour (~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 run

New 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
Terminal window
git add main.gd tests/test_haptics_main.gd
git 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 (the build: null entry 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
Terminal window
git add docs/architecture/coop-and-pilots.md docs/architecture/dev-tools.md control/src/whats_new.js
git 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.


  • 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_events forwarding like telemetry (Task 5 _drive_haptics after the drain); one const block (Task 2); HapticsOutput joypad / touch / none routing + injectable spy (Task 1); per-pilot device list from main, any pilot count (Task 1 routes_for, Task 5); TouchControls re-pointed to the one flag (Task 3); meta.haptics default true, saved like share_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 new build: null (Task 6); no privacy change (Global Constraints).
  • Placeholders: none; commit messages are given inline as the one-line summary to put in the --file message.
  • 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_toggled are used with the same names and signatures in every task.
  • Written 2026-10-05 by a planning subagent in worktree .claude/worktrees/coop-haptics-specs (branch coop-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 . --import in the worktree (it created the gitignored .godot/ cache and rewrote audio/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_for already handles any pilot count, so the order between the two plans does not matter.
  • Next action: execute Task 1 (consult bh-sdd-concurrent first).