Skip to content

Premium haptics: iPhone CoreHaptics (plan)

Premium Haptics (iPhone CoreHaptics) Implementation Plan

Section titled “Premium Haptics (iPhone CoreHaptics) 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, consult bh-sdd-concurrent before Task 1 and dispatch implementers (bh-implementer, model: "opus") into their own worktree (.claude/worktrees/<name>, branch off origin/main).

Goal: Drive the iPhone Taptic Engine through CoreHaptics (transients, sharpness, curves) with every gameplay haptic redesigned as data, four new events (Leech heal, weapon evolution, low-hull heartbeat, NUKE) plus a warp-ready tick, pad fallbacks for every pattern, and live tuning from Debug Settings and the Remote Console.

Architecture: A small ObjC++ godot-cpp GDExtension (DcHaptics, iOS + a macOS stub so the macOS editor that exports iOS loads it cleanly) exposes supports_haptics / prepare / play_ahap(json) / stop_all as an Engine singleton. Everything else stays GDScript and headless-testable: HapticPatterns loads data/haptics/<id>.json (an Apple AHAP dictionary + a two-motor pad fallback), HapticsOutput.play(pilot, id, scale…) sends the scaled AHAP to DcHaptics on a Taptic iPhone’s handheld route and schedules the pad pulses everywhere else, and HapticsDirector maps frame-diffed pilot state to pattern ids. The only sim change is a render-read PlayerState.leech_healed counter, outside the checksum.

Tech Stack: Godot 4.7.1 (stock, iOS), typed GDScript (warnings as errors), GUT 9.7.1, godot-cpp godot-4.5-stable + SCons + Xcode 27 (xcodebuild -create-xcframework), CoreHaptics, the Remote Console (control/, node tests).

Spec: docs/superpowers/specs/2026-10-06-corehaptics-premium-design.md (research: docs/research/2026-10-05-corehaptics-godot.md). Executors read both.

Deviations from the spec (ALL 8 APPROVED by Chris, 2026-10-06)

Section titled “Deviations from the spec (ALL 8 APPROVED by Chris, 2026-10-06)”

Each was forced by something in the code or the build that the spec did not know about.

  1. Patterns live in data/haptics/<id>.json, not haptics/patterns/. platform/tvos/ (the iOS export project) only sees symlinked root dirs; data/ is one, a new haptics/ dir is not. Godot exports .json files already (data/bible.json).
  2. Native API is play_ahap(json: String), not play_pattern(json, intensity_scale, sharpness_scale) + play_transient. Scaling happens in GDScript (HapticPatterns.scaled_ahap), where GUT can test it; the extension only parses, caches and plays. play_transient is dropped: the damage-scaled hit tap is the hit pattern at a scale.
  3. Extension binaries are gitignored and built by build.sh, exactly like Sentry (which is fetched, never committed; the spec’s “committed, as with Sentry” contradicted itself). build-ios.sh and install-dev.sh call build.sh, which is a no-op when its stamp matches. The library is a dynamic framework inside an .xcframework, the shape Sentry ships and the iOS export already embeds (a static godot-cpp copy could collide with another extension’s). godot-cpp is pinned to the godot-4.5-stable tag (there is no 4.7 tag; GDExtension is forward-compatible and Sentry also declares compatibility_minimum = "4.5"). A macOS dylib is built too, so the macOS editor that runs the iOS export loads the extension without errors; supports_haptics() is false there.
  4. The Haptics Lab is a Debug Settings cycle/confirm row plus a console “Play haptic” picker, not a separate panel. tests/test_debug_console_parity.gd requires every Debug Settings button to map to a console command; a standalone panel would either dodge that guard or need a new contract. The intensity/sharpness sliders are the console’s Tunables tab (new “Haptics” group), which also works while Chris holds the phone. The row shows which path last played (native / pad / none).
  5. Evolution is detected by diffing each pilot’s evolved-weapon count, render-side. The sim emits no evolve fx event (verified: UpgradeSystem.evolve_weapon and the crystal-rung path in Sim only call w.evolve() + log_op).
  6. hit_heavy is its own pattern id (13 ids, not 12): the heavy-hit tail is a different AHAP, not a branch inside hit.
  7. The haptics system owns the warp/decoy buzz on iPhone (Chris, 2026-10-06: “change it to the new haptics system”). Today TouchControls.fire_dash/fire_decoy buzz the phone themselves with a plain vibrate_handheld, and the director deliberately skips the phone for those two. The plan deletes the buttons’ own warp/decoy buzz and lets the director play warp/decoy on every route, phone included, so what is tuned in the Haptic Lab is what plays in a run. Behaviour change: the buzz now confirms the warp/decoy HAPPENED (one frame after the press), so pressing a button that is still on cooldown no longer buzzes. The long-hold Drone Bay tap (fire_bay) is a UI action, not a gameplay event, and keeps its own tap.
  8. Tunable keys follow the registry convention HapticsOutput.INTENSITY, HapticsOutput.SHARPNESS, HapticsOutput.<ID>_INTENSITY, plus HapticsDirector.HEARTBEAT_FRAC / HEARTBEAT_EVERY_S (the spec’s risk section asks for those two to be tunable), instead of haptics.intensity.
  • Godot 4.7.1, typed GDScript, warnings as errors. Annotate Dictionary.get() results as Variant (or an explicit type), never inferred :=.
  • /sim stays pure. The only sim edit is Task 5 (PlayerState.leech_healed + its increment). The three seed-1234 baselines (tests/test_determinism_survival.gd, tests/test_determinism.gd, tests/test_determinism_coop.gd) must pass unchanged: a re-pin is a defect in this plan, not a ruling.
  • The root project never loads the extension. GDScript reaches it ONLY via Engine.has_singleton("DcHaptics") / Engine.get_singleton("DcHaptics") and Object.call(...); never write the bare identifier DcHaptics in GDScript (parse error in the root project).
  • HapticsOutput.enabled == false short-circuits before any native play call. (stop_all() still stops the native engine while disabled, since switching off must silence a playing pattern, and init_native() prepares the engine at boot whatever meta.haptics says, per spec §7. Pre-flight F8.)
  • Haptics failures never surface to gameplay: native errors are logged once per error code and swallowed.
  • Run tests with scripts/check-test-count.sh (full log at $TMPDIR/bh-gut-last.log), never a raw GUT run. Finish every task with scripts/gate.sh green.
  • Node tests: node --test scripts/test/*.test.mjs and node --test control/test/*.test.mjs (file globs, not the directory form).
  • After any windowed or platform/tvos Godot run: git status, and restore project.godot, audio/bus_layout.tres, platform/tvos/project.godot if Godot rewrote them.
  • Commit with git commit --file=<msgfile>; end messages with the session’s Co-Authored-By / Claude-Session lines.
  • Player-visible copy: no em dashes in What’s new; the decoy is “Decoy”, never “Sentinel”.
  • Every new docs/**/*.md needs Starlight title: frontmatter.
  • Pattern ids (exact, in this order): hit, hit_heavy, down, level_up, evolve, leech, heartbeat, boss_windup, boss_kill, nuke, warp, decoy, warp_ready.
File Status Responsibility
platform/tvos/addons/dc_haptics/src/dc_haptics.h, dc_haptics.mm, register_types.mm new (tracked) the ObjC++ extension
platform/tvos/addons/dc_haptics/SConstruct, build.sh, dc_haptics.gdextension new (tracked) build + load manifest
platform/tvos/addons/dc_haptics/{bin,out}/, src/*.os, .sconsign.dblite new (gitignored) build output
platform/tvos/export_presets.cfg modify exclude the addon from tvOS/Android/macOS presets
scripts/release/build-ios.sh, scripts/dev/install-dev.sh, scripts/release/sentry-guard.sh modify build the addon; guard a pack-only shell
data/haptics/*.json (13) new patterns as data
input/haptic_patterns.gd new load, validate, scale patterns
input/haptics_output.gd modify play(), pad scheduler, native routing, gains, init_native
input/haptics_director.gd modify events → pattern ids; leech, evolve, heartbeat, nuke, warp_ready
sim/player_state.gd, sim/sim.gd modify leech_healed (Task 5 only)
ui/touch_controls.gd modify drop the WARP/DRONE buttons’ own buzz (the director owns it)
ui/debug_settings_panel.gd, main.gd modify Haptic Lab row, haptic_play command, caps, init_native at boot, native stop on background
control/src/panel/{markup,client,tooltips}.js, control/src/whats_new.js modify console Play-haptic picker, What’s new
net/tunables_registry.gd, tools/tunable_docs/docs.d/haptics.json, tools/tunable_docs/generate.mjs, control/src/tunable_docs.js modify/new haptics tunables (the manifest comes with the next BUILD bump)
tests/fake_dc_haptics.gd, tests/haptics_spy.gd, tests/test_haptic_patterns.gd, tests/test_haptics_output.gd, tests/test_haptics_director.gd, tests/test_leech.gd, tests/test_release_presets.gd, tests/test_haptics_tunables.gd new/modify tests
docs/architecture/coop-and-pilots.md modify Haptics section + feel-check list

Task 1: DcHaptics extension, build pipeline, boot-time init (load spike)

Section titled “Task 1: DcHaptics extension, build pipeline, boot-time init (load spike)”

Files:

  • Create: platform/tvos/addons/dc_haptics/src/dc_haptics.h, platform/tvos/addons/dc_haptics/src/dc_haptics.mm, platform/tvos/addons/dc_haptics/src/register_types.mm, platform/tvos/addons/dc_haptics/SConstruct, platform/tvos/addons/dc_haptics/build.sh, platform/tvos/addons/dc_haptics/dc_haptics.gdextension, tests/fake_dc_haptics.gd
  • Modify: .gitignore (after the end of the sentry block, line 82), platform/tvos/export_presets.cfg (exclude_filter of presets 0 tvOS, 2 tvOS-Dev, 4 Android, 5 Android-Dev, 6 macOS), scripts/release/build-ios.sh:6, scripts/dev/install-dev.sh:60-61, scripts/release/sentry-guard.sh (ios-shell), input/haptics_output.gd, main.gd:330 (main._notification at 1848-1851 needs NO edit: it already calls haptics.output.stop_all())
  • Test: tests/test_release_presets.gd, tests/test_haptics_output.gd

Interfaces:

  • Produces (native, via Engine.get_singleton("DcHaptics")): supports_haptics() -> bool, prepare() -> void, play_ahap(json: String) -> void, stop_all() -> void.

  • Produces (GDScript): HapticsOutput.native: Object (static, null unless a Taptic device has the extension), HapticsOutput.NATIVE_SINGLETON := "DcHaptics", static func init_native() -> void. HapticsOutput.stop_all() also calls native.stop_all().

  • Produces (tests): FakeDcHaptics (tests/fake_dc_haptics.gd) with supports: bool, calls: Array, of_kind(kind) -> Array; each play_ahap call is recorded as {"kind": "ahap", "ahap": <parsed Dictionary>}.

  • Step 1: Write the failing preset test

Append to tests/test_release_presets.gd:

func test_dc_haptics_ships_on_ios_only() -> void:
# The DcHaptics GDExtension (CoreHaptics plan Task 1) has iOS + macOS-editor libraries only.
# tvOS/Android have none, and the Mac App Store build must not embed the editor-side stub.
for p in _presets():
var parts: Array = Array(String(p.exclude).split(",")).map(func(x: String) -> String: return x.strip_edges())
if String(p.name).begins_with("iOS"):
assert_false("addons/dc_haptics/*" in parts, "%s must ship dc_haptics" % p.name)
else:
assert_true("addons/dc_haptics/*" in parts, "%s must exclude addons/dc_haptics/*" % p.name)
  • Step 2: Write the failing init_native tests

Create tests/fake_dc_haptics.gd:

class_name FakeDcHaptics
extends RefCounted
# Test double for the iOS DcHaptics GDExtension singleton (absent headless and in the root project).
# Records every call; play_ahap's JSON is parsed so tests can assert on the scaled values.
var supports := true
var calls: Array = []
func supports_haptics() -> bool:
return supports
func prepare() -> void:
calls.append({"kind": "prepare"})
func play_ahap(json: String) -> void:
calls.append({"kind": "ahap", "ahap": JSON.parse_string(json)})
func stop_all() -> void:
calls.append({"kind": "native_stop"})
func of_kind(kind: String) -> Array:
var out: Array = []
for c: Dictionary in calls:
if String(c["kind"]) == kind:
out.append(c)
return out

Append to tests/test_haptics_output.gd. Add a member var _fake: FakeDcHaptics = null (an Engine singleton holds no reference, so the test keeps the RefCounted fake alive until after_each unregisters it; pre-flight F13b), and add to after_each: HapticsOutput.native = null, then if Engine.has_singleton(HapticsOutput.NATIVE_SINGLETON): Engine.unregister_singleton(HapticsOutput.NATIVE_SINGLETON), then _fake = null:

func test_init_native_without_the_extension_leaves_native_null() -> void:
HapticsOutput.init_native()
assert_null(HapticsOutput.native, "root project / tvOS / Android: no DcHaptics singleton")
func test_init_native_on_a_device_without_a_taptic_engine_stays_on_pads() -> void:
_fake = FakeDcHaptics.new()
_fake.supports = false
Engine.register_singleton(HapticsOutput.NATIVE_SINGLETON, _fake)
HapticsOutput.init_native()
assert_null(HapticsOutput.native, "iPad / Mac: the extension loads but has no Taptic Engine")
assert_eq(_fake.of_kind("prepare").size(), 0)
func test_init_native_prepares_a_taptic_device() -> void:
_fake = FakeDcHaptics.new()
Engine.register_singleton(HapticsOutput.NATIVE_SINGLETON, _fake)
HapticsOutput.init_native()
assert_eq(HapticsOutput.native, _fake)
assert_eq(_fake.of_kind("prepare").size(), 1)
func test_stop_all_also_stops_the_native_engine() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := HapticsSpy.new()
spy.routes = [{"joypads": [], "handheld": true}]
spy.stop_all()
assert_eq(fake.of_kind("native_stop").size(), 1)
  • Step 3: Run the tests to verify they fail

Run: scripts/check-test-count.sh Expected: FAIL: test_dc_haptics_ships_on_ios_only (presets lack the exclude) and the init_native tests (init_native / NATIVE_SINGLETON not defined).

  • Step 4: Add the native surface to HapticsOutput

In input/haptics_output.gd, below static var enabled := true:

# The DcHaptics GDExtension singleton (platform/tvos/addons/dc_haptics, iOS export only) when it is
# loaded AND the device has a Taptic Engine; null everywhere else (root project, tvOS, Android, Mac,
# iPad). Set once by init_native() at boot; tests assign a FakeDcHaptics. Reached only through
# Engine.get_singleton + call(): the root project has no such class, so never name it directly.
static var native: Object = null
const NATIVE_SINGLETON := "DcHaptics"
static func init_native() -> void:
native = null
if not Engine.has_singleton(NATIVE_SINGLETON):
return
var n: Object = Engine.get_singleton(NATIVE_SINGLETON)
if not bool(n.call("supports_haptics")):
print("DcHaptics: loaded, no Taptic Engine on this device (pad patterns only)")
return
n.call("prepare")
native = n
print("DcHaptics: ready")

and make stop_all() end with:

if native != null:
native.call("stop_all")
  • Step 5: Call it at boot and on backgrounding

main.gd:330, right after HapticsOutput.enabled = meta.haptics:

HapticsOutput.init_native() # iPhone: start the CoreHaptics engine (DcHaptics); no-op elsewhere

main._notification already calls haptics.output.stop_all(), which now stops the native engine too; no other change there.

  • Step 6: Exclude the addon outside iOS

In platform/tvos/export_presets.cfg, append , addons/dc_haptics/* to the exclude_filter of presets 0 (tvOS), 2 (tvOS-Dev), 4 (Android), 5 (Android-Dev) and 6 (macOS). Leave presets 1 (iOS) and 3 (iOS-Dev) alone.

  • Step 7: Run the tests to verify they pass

Run: scripts/check-test-count.sh Expected: PASS (all, including the 5 new tests).

  • Step 8: Write the native extension

platform/tvos/addons/dc_haptics/src/dc_haptics.h:

// DcHaptics: CoreHaptics for the iPhone Taptic Engine (spec
// docs/superpowers/specs/2026-10-06-corehaptics-premium-design.md). GDScript reaches it only through
// Engine.get_singleton("DcHaptics"); input/haptics_output.gd owns every gameplay decision.
#pragma once
#include <godot_cpp/classes/object.hpp>
#include <godot_cpp/variant/string.hpp>
namespace godot {
class DcHaptics : public Object {
GDCLASS(DcHaptics, Object);
void *impl = nullptr; // DcHapticsImpl *, retained via __bridge_retained (ObjC, ARC)
protected:
static void _bind_methods();
public:
DcHaptics();
~DcHaptics();
bool supports_haptics() const;
void prepare();
void play_ahap(const String &json);
void stop_all();
};
} // namespace godot

platform/tvos/addons/dc_haptics/src/dc_haptics.mm:

#import <CoreHaptics/CoreHaptics.h>
#import <Foundation/Foundation.h>
#include "dc_haptics.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
using namespace godot;
static const NSUInteger kPatternCacheMax = 64; // parsed patterns, keyed by their AHAP JSON
static const NSUInteger kPlayersKept = 16; // recent one-shot players kept alive while they play
@interface DcHapticsImpl : NSObject
@property(nonatomic, strong) CHHapticEngine *engine;
@property(atomic) BOOL running; // written from CoreHaptics' handler threads
@property(nonatomic, strong) NSMutableDictionary<NSString *, CHHapticPattern *> *cache;
@property(nonatomic, strong) NSMutableArray *players;
@property(nonatomic, strong) NSMutableSet<NSNumber *> *logged;
@end
@implementation DcHapticsImpl
- (instancetype)init {
if ((self = [super init])) {
_cache = [NSMutableDictionary dictionary];
_players = [NSMutableArray array];
_logged = [NSMutableSet set];
}
return self;
}
// One warning per distinct error code: a haptics failure never reaches gameplay.
- (void)logOnce:(NSError *)err context:(const char *)what {
NSNumber *code = @(err != nil ? err.code : -1);
if ([self.logged containsObject:code]) {
return;
}
[self.logged addObject:code];
NSString *msg = err != nil ? err.localizedDescription : @"unknown error";
UtilityFunctions::push_warning(String("DcHaptics ") + String(what) + ": " + String(msg.UTF8String));
}
- (void)prepare {
if (self.engine != nil) {
return;
}
NSError *err = nil;
CHHapticEngine *e = [[CHHapticEngine alloc] initAndReturnError:&err];
if (e == nil) {
[self logOnce:err context:"init"];
return;
}
e.playsHapticsOnly = YES; // never touches the game's audio session
e.autoShutdownEnabled = YES; // idle engine powers down; ensureRunning restarts it
__weak DcHapticsImpl *weakSelf = self;
e.stoppedHandler = ^(CHHapticEngineStoppedReason reason) {
weakSelf.running = NO; // backgrounded, audio interruption, idle shutdown...
};
e.resetHandler = ^{
weakSelf.running = NO; // the server reset: restart lazily on the next play
};
self.engine = e;
[self ensureRunning];
}
- (BOOL)ensureRunning {
if (self.engine == nil) {
return NO;
}
if (self.running) {
return YES;
}
NSError *err = nil;
if ([self.engine startAndReturnError:&err]) {
self.running = YES;
return YES;
}
[self logOnce:err context:"start"];
return NO;
}
- (void)play:(NSString *)json {
if (![self ensureRunning]) {
return;
}
NSError *err = nil;
CHHapticPattern *pattern = self.cache[json];
if (pattern == nil) {
NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding];
id dict = data != nil ? [NSJSONSerialization JSONObjectWithData:data options:0 error:&err] : nil;
if (![dict isKindOfClass:[NSDictionary class]]) {
[self logOnce:err context:"json"];
return;
}
pattern = [[CHHapticPattern alloc] initWithDictionary:dict error:&err];
if (pattern == nil) {
[self logOnce:err context:"pattern"];
return;
}
if (self.cache.count >= kPatternCacheMax) {
[self.cache removeAllObjects];
}
self.cache[json] = pattern;
}
id<CHHapticPatternPlayer> player = [self.engine createPlayerWithPattern:pattern error:&err];
if (player == nil) {
[self logOnce:err context:"player"];
return;
}
if (![player startAtTime:CHHapticTimeImmediate error:&err]) {
[self logOnce:err context:"play"];
self.running = NO; // the engine may have stopped under us; restart on the next call
return;
}
[self.players addObject:player];
if (self.players.count > kPlayersKept) {
[self.players removeObjectAtIndex:0];
}
}
- (void)stopAll {
[self.players removeAllObjects];
if (self.engine == nil || !self.running) {
return;
}
self.running = NO;
[self.engine stopWithCompletionHandler:nil];
}
@end
static inline DcHapticsImpl *impl_of(void *p) {
return (__bridge DcHapticsImpl *)p;
}
void DcHaptics::_bind_methods() {
ClassDB::bind_method(D_METHOD("supports_haptics"), &DcHaptics::supports_haptics);
ClassDB::bind_method(D_METHOD("prepare"), &DcHaptics::prepare);
ClassDB::bind_method(D_METHOD("play_ahap", "json"), &DcHaptics::play_ahap);
ClassDB::bind_method(D_METHOD("stop_all"), &DcHaptics::stop_all);
}
DcHaptics::DcHaptics() {
impl = (__bridge_retained void *)[[DcHapticsImpl alloc] init];
}
DcHaptics::~DcHaptics() {
if (impl != nullptr) {
DcHapticsImpl *i = (__bridge_transfer DcHapticsImpl *)impl;
[i stopAll];
impl = nullptr;
}
}
bool DcHaptics::supports_haptics() const {
return [CHHapticEngine capabilitiesForHardware].supportsHaptics;
}
void DcHaptics::prepare() {
[impl_of(impl) prepare];
}
void DcHaptics::play_ahap(const String &json) {
NSString *s = [NSString stringWithUTF8String:json.utf8().get_data()];
if (s != nil) {
[impl_of(impl) play:s];
}
}
void DcHaptics::stop_all() {
[impl_of(impl) stopAll];
}

platform/tvos/addons/dc_haptics/src/register_types.mm:

#include "dc_haptics.h"
#include <gdextension_interface.h>
#include <godot_cpp/classes/engine.hpp>
#include <godot_cpp/core/defs.hpp>
#include <godot_cpp/godot.hpp>
using namespace godot;
static DcHaptics *dc_haptics_singleton = nullptr;
static void initialize_dc_haptics(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE) {
return;
}
GDREGISTER_CLASS(DcHaptics);
dc_haptics_singleton = memnew(DcHaptics);
Engine::get_singleton()->register_singleton("DcHaptics", dc_haptics_singleton);
}
static void uninitialize_dc_haptics(ModuleInitializationLevel p_level) {
if (p_level != MODULE_INITIALIZATION_LEVEL_SCENE || dc_haptics_singleton == nullptr) {
return;
}
Engine::get_singleton()->unregister_singleton("DcHaptics");
memdelete(dc_haptics_singleton);
dc_haptics_singleton = nullptr;
}
extern "C" {
GDExtensionBool GDE_EXPORT dc_haptics_init(GDExtensionInterfaceGetProcAddress p_get_proc_address,
const GDExtensionClassLibraryPtr p_library, GDExtensionInitialization *r_initialization) {
godot::GDExtensionBinding::InitObject init_obj(p_get_proc_address, p_library, r_initialization);
init_obj.register_initializer(initialize_dc_haptics);
init_obj.register_terminator(uninitialize_dc_haptics);
init_obj.set_minimum_library_initialization_level(MODULE_INITIALIZATION_LEVEL_SCENE);
return init_obj.init();
}
}

platform/tvos/addons/dc_haptics/SConstruct:

#!/usr/bin/env python
# DcHaptics GDExtension (CoreHaptics plan Task 1). Run by build.sh, which sets GODOT_CPP to a pinned
# godot-cpp checkout outside the repo. Output goes to out/ (gitignored); build.sh assembles bin/.
import os
env = SConscript(os.path.join(os.environ["GODOT_CPP"], "SConstruct"))
lib_env = env.Clone() # our flags must not leak onto godot-cpp's own objects
lib_env.Append(CPPPATH=["src/"], CCFLAGS=["-fobjc-arc"],
LINKFLAGS=["-framework", "CoreHaptics", "-framework", "Foundation"])
sources = Glob("src/*.mm")
cfg = "debug" if env["target"] == "template_debug" else "release"
if env["platform"] == "ios":
name = "libdc_haptics.ios.%s" % cfg
lib_env.Append(LINKFLAGS=["-Wl,-install_name,@rpath/%s.framework/%s" % (name, name)])
library = lib_env.SharedLibrary("out/ios/%s" % name, sources, SHLIBSUFFIX="")
else:
library = lib_env.SharedLibrary("out/macos/libdc_haptics.macos.%s.dylib" % cfg, sources, SHLIBSUFFIX="")
Default(library)

platform/tvos/addons/dc_haptics/build.sh (mode 755):

#!/usr/bin/env bash
# Builds the DcHaptics GDExtension into bin/ (gitignored, like addons/sentry): an iOS device
# .xcframework holding a dynamic framework (what the iOS export embeds) and a macOS dylib (so the
# macOS editor that runs the iOS export loads the addon cleanly; supports_haptics() is false there).
# godot-cpp is pinned by tag + commit and cached outside the repo. Idempotent: a matching bin/.stamp
# (hash of the sources, this script and the pin) skips the build. Run by build-ios.sh / install-dev.sh.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
CPP_TAG=godot-4.5-stable
CPP_SHA=e83fd0904c13356ed1d4c3d09f8bb9132bdc6b77
CACHE=${BH_GODOT_CPP_CACHE:-$HOME/Library/Caches/bh-godot-cpp}
CPP=$CACHE/$CPP_SHA
MIN_IOS=15.0
STAMP=$( (cd "$HERE" && cat SConstruct build.sh dc_haptics.gdextension src/*.h src/*.mm; echo "$CPP_SHA") | shasum -a 256 | cut -d' ' -f1)
done_already() {
[ -f "$HERE/bin/.stamp" ] && [ "$(cat "$HERE/bin/.stamp")" = "$STAMP" ] \
&& [ -d "$HERE/bin/ios/libdc_haptics.ios.release.xcframework" ] \
&& [ -d "$HERE/bin/ios/libdc_haptics.ios.debug.xcframework" ] \
&& [ -f "$HERE/bin/macos/libdc_haptics.macos.debug.dylib" ] \
&& [ -f "$HERE/bin/macos/libdc_haptics.macos.release.dylib" ]
}
if done_already; then echo "dc_haptics: up to date"; exit 0; fi
if [ ! -d "$CPP/.git" ]; then
mkdir -p "$CACHE"; rm -rf "$CPP"
git clone --quiet --depth 1 --branch "$CPP_TAG" https://github.com/godotengine/godot-cpp "$CPP"
fi
[ "$(git -C "$CPP" rev-parse HEAD)" = "$CPP_SHA" ] || { echo "dc_haptics: $CPP is not godot-cpp $CPP_SHA, refusing"; exit 1; }
cd "$HERE"
mkdir -p out && touch out/.gdignore
JOBS=$(sysctl -n hw.ncpu)
for T in template_debug template_release; do
GODOT_CPP="$CPP" scons -j"$JOBS" platform=ios arch=arm64 ios_simulator=no ios_min_version=$MIN_IOS target=$T
GODOT_CPP="$CPP" scons -j"$JOBS" platform=macos arch=arm64 target=$T
done
rm -rf bin && mkdir -p bin/ios bin/macos
for C in debug release; do
N=libdc_haptics.ios.$C
FW=out/fw/$N.framework
rm -rf "$FW" && mkdir -p "$FW"
cp "out/ios/$N" "$FW/$N"
cat > "$FW/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>$N</string>
<key>CFBundleIdentifier</key><string>digital.lumara.dchaptics.$C</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>$N</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
<key>CFBundleVersion</key><string>1.0</string>
<key>MinimumOSVersion</key><string>$MIN_IOS</string>
</dict>
</plist>
PLIST
xcodebuild -create-xcframework -framework "$FW" -output "bin/ios/$N.xcframework" >/dev/null
cp "out/macos/libdc_haptics.macos.$C.dylib" bin/macos/
done
echo "$STAMP" > bin/.stamp
echo "dc_haptics: built -> $HERE/bin"

platform/tvos/addons/dc_haptics/dc_haptics.gdextension:

[configuration]
entry_symbol = "dc_haptics_init"
compatibility_minimum = "4.5"
[libraries]
ios.debug = "res://addons/dc_haptics/bin/ios/libdc_haptics.ios.debug.xcframework"
ios.release = "res://addons/dc_haptics/bin/ios/libdc_haptics.ios.release.xcframework"
macos.debug = "res://addons/dc_haptics/bin/macos/libdc_haptics.macos.debug.dylib"
macos.release = "res://addons/dc_haptics/bin/macos/libdc_haptics.macos.release.dylib"

Append to .gitignore after the platform/tvos/sentry_dsn.json line (line 82, the end of the sentry block that starts at line 79; don’t split that block; no inline comments in that file):

# DcHaptics GDExtension build output (CoreHaptics plan Task 1): rebuilt by platform/tvos/addons/dc_haptics/build.sh
platform/tvos/addons/dc_haptics/bin/
platform/tvos/addons/dc_haptics/out/
platform/tvos/addons/dc_haptics/src/*.os
platform/tvos/addons/dc_haptics/.sconsign.dblite
  • Step 9: Build it and check the artefacts

Run: bash platform/tvos/addons/dc_haptics/build.sh (first run clones and compiles godot-cpp: several minutes). Then:

Terminal window
B=platform/tvos/addons/dc_haptics/bin
file $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release
otool -D $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release
otool -L $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release | grep -c CoreHaptics
nm -gU $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release | grep dc_haptics_init
otool -l $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release | grep -A3 LC_BUILD_VERSION | grep minos
bash platform/tvos/addons/dc_haptics/build.sh

Expected: Mach-O 64-bit dynamically linked shared library arm64; install name @rpath/libdc_haptics.ios.release.framework/libdc_haptics.ios.release; 1; one _dc_haptics_init line; minos 15.0; the second build.sh run prints dc_haptics: up to date. If nm shows many exported godot:: symbols, add -fvisibility=hidden to lib_env’s CCFLAGS (godot-cpp already builds hidden by default; only dc_haptics_init should be exported).

  • Step 10: Prove the macOS editor loads it

Create $TMPDIR/dc_probe.gd:

extends SceneTree
func _init() -> void:
var ok := Engine.has_singleton("DcHaptics")
var sup := ok and bool(Engine.get_singleton("DcHaptics").call("supports_haptics"))
print("DCPROBE singleton=%s supports=%s" % [ok, sup])
quit()

Run from platform/tvos/: godot --headless --path . --import > /dev/null 2>&1; cp $TMPDIR/dc_probe.gd ./dc_probe.gd && godot --headless --path . -s res://dc_probe.gd 2>&1 | grep -E "DCPROBE|ERROR|GDExtension"; rm -f dc_probe.gd dc_probe.gd.uid; git -C ../.. status --short platform/tvos Expected: DCPROBE singleton=true supports=false and no GDExtension error line. Restore platform/tvos/project.godot if the status shows it modified.

  • Step 11: Wire the build into the iOS pipelines and the shell guard

scripts/release/build-ios.sh, after the fetch-sentry.sh line (line 6):

Terminal window
bash "$TVOS/addons/dc_haptics/build.sh" # CoreHaptics: DcHaptics GDExtension (iOS libs, gitignored)

scripts/dev/install-dev.sh, after the iOS-branch fetch-sentry.sh line (line 60):

Terminal window
bash "$ROOT/platform/tvos/addons/dc_haptics/build.sh" # CoreHaptics: DcHaptics GDExtension

scripts/release/sentry-guard.sh, inside ios-shell) after the sentry if … fi block, add the same check for the new addon (a pack-only export never adds a GDExtension to the Xcode shell):

Terminal window
if [ -f "$TV/addons/dc_haptics/dc_haptics.gdextension" ] && ! grep -q libdc_haptics "$PBX" 2>/dev/null; then
echo "sentry-guard: $PBX does not embed libdc_haptics (the DcHaptics CoreHaptics extension);"
echo " a pack-only export never adds a GDExtension. Do the same full export as for libsentry:"
echo " cd platform/tvos && /opt/homebrew/bin/godot --path . --rendering-method mobile --export-release \"iOS\" build-ios/BulletHeaven.xcodeproj; git checkout -- project.godot"
echo " cp patches/ios-BulletHeaven-Info.plist build-ios/BulletHeaven/BulletHeaven-Info.plist"
echo " then re-run scripts/dev/install-dev.sh <iphone|ipad>."
exit 1
fi

Update the script’s header comment to say ios-shell checks both addons.

  • Step 12: Gate and commit

Run: scripts/gate.sh → green. Then:

Terminal window
git add .gitignore platform/tvos/addons/dc_haptics/src platform/tvos/addons/dc_haptics/SConstruct \
platform/tvos/addons/dc_haptics/build.sh platform/tvos/addons/dc_haptics/dc_haptics.gdextension \
platform/tvos/export_presets.cfg scripts/release/build-ios.sh scripts/dev/install-dev.sh \
scripts/release/sentry-guard.sh input/haptics_output.gd main.gd tests/fake_dc_haptics.gd \
tests/test_haptics_output.gd tests/test_release_presets.gd
git commit --file=$TMPDIR/msg # "feat(haptics): DcHaptics CoreHaptics GDExtension + build pipeline + boot init"

(Also git add any .uid files Godot generated for the new .gd/.gdextension files.)

  • Step 13: Device load check (CONTROLLER + CHRIS, not the implementer)

This needs Chris’s iPhone and does not block Tasks 2-8 (they are headless). Follow skill bh-worktree-device-install for the BUILD bump and install. Because the shell needs the new framework, do the full export the guard prints (Step 11), re-copy the iOS plist patch, then scripts/dev/install-dev.sh iphone. Launch with the console attached:

xcrun devicectl device process launch --device <iphone-udid> --console digital.lumara.bulletheaven.dev 2>&1 | grep -m3 -E "DcHaptics|GDExtension"

Expected: DcHaptics: ready, no GDExtension error. If the extension fails to load, STOP and report: the fallback (spec Risks) is a .gdip plugin with the same four methods registered as an Engine singleton, which changes only this task’s native packaging; GDScript is unaffected.


Task 2: Patterns as data (HapticPatterns + 13 JSON files)

Section titled “Task 2: Patterns as data (HapticPatterns + 13 JSON files)”

Files:

  • Create: input/haptic_patterns.gd, data/haptics/{hit,hit_heavy,down,level_up,evolve,leech,heartbeat,boss_windup,boss_kill,nuke,warp,decoy,warp_ready}.json
  • Test: tests/test_haptic_patterns.gd

Interfaces:

  • Produces: HapticPatterns.IDS: Array[String] (the 13 ids, Global Constraints order), HapticPatterns.DIR := "res://data/haptics/", static func load_file(id: String) -> Dictionary, static func pad(id: String) -> Array (of {weak, strong, ms, at_ms[, min_ms]}), static func scaled_ahap(id: String, intensity: float, sharpness: float) -> String ("" for an unknown id), static func problems(d: Dictionary) -> Array[String] ([] = valid).

  • Pad pulse semantics (used by Task 3): weak/strong 0..1 are the two motors at scale 1; at_ms is the offset from the play; a pulse WITH min_ms has duration max(min_ms, round(ms × scale)), a pulse without it has fixed ms.

  • Step 1: Write the failing tests

Create tests/test_haptic_patterns.gd:

extends GutTest
# Haptic patterns as data (CoreHaptics plan Task 2): every data/haptics/<id>.json is a valid AHAP +
# pad fallback, and the validator really rejects what it claims to.
func _valid() -> Dictionary:
return {
"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [
{"ParameterID": "HapticIntensity", "ParameterValue": 0.5},
{"ParameterID": "HapticSharpness", "ParameterValue": 0.5}]}},
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.2, "EventParameters": [
{"ParameterID": "HapticIntensity", "ParameterValue": 0.5}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.0,
"ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 1.0}, {"Time": 0.2, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 0.5, "strong": 0.0, "ms": 30, "at_ms": 0}, {"weak": 0.5, "strong": 0.0, "ms": 30, "at_ms": 80}],
}
func test_every_id_has_a_valid_file() -> void:
for id: String in HapticPatterns.IDS:
var d := HapticPatterns.load_file(id)
assert_false(d.is_empty(), "%s.json exists and parses" % id)
assert_eq(HapticPatterns.problems(d), [] as Array[String], "%s.json is valid" % id)
func test_the_data_dir_holds_exactly_the_ids() -> void:
var files: Array[String] = []
for f: String in DirAccess.get_files_at(HapticPatterns.DIR):
if f.ends_with(".json"):
files.append(f.trim_suffix(".json"))
files.sort()
var ids := HapticPatterns.IDS.duplicate()
ids.sort()
assert_eq(files, ids, "no orphan pattern file and no id without one")
func test_the_fixture_is_valid() -> void:
assert_eq(HapticPatterns.problems(_valid()), [] as Array[String])
func test_validator_rejects_bad_patterns() -> void:
var cases: Array[Callable] = [
func(d: Dictionary) -> void: d["extra"] = 1,
func(d: Dictionary) -> void: d["ahap"]["Version"] = 2.0,
func(d: Dictionary) -> void: d["ahap"]["Pattern"] = [],
func(d: Dictionary) -> void: d["ahap"]["Pattern"][0]["Event"]["EventParameters"][0]["ParameterValue"] = 1.5,
func(d: Dictionary) -> void: d["ahap"]["Pattern"][0]["Event"]["EventParameters"][0]["ParameterID"] = "Loudness",
func(d: Dictionary) -> void: d["ahap"]["Pattern"][0]["Event"]["EventDuration"] = 0.1,
func(d: Dictionary) -> void: d["ahap"]["Pattern"][1]["Event"].erase("EventDuration"),
func(d: Dictionary) -> void: d["ahap"]["Pattern"][0]["Event"]["Bogus"] = 1,
func(d: Dictionary) -> void: d["ahap"]["Pattern"][2]["ParameterCurve"]["ParameterID"] = "HapticIntensity",
func(d: Dictionary) -> void: d["ahap"]["Pattern"][2]["ParameterCurve"]["ParameterCurveControlPoints"][1]["Time"] = -1.0,
func(d: Dictionary) -> void: d["pad"] = [],
func(d: Dictionary) -> void: d["pad"][0]["weak"] = 1.2,
func(d: Dictionary) -> void: d["pad"][1]["at_ms"] = -5,
func(d: Dictionary) -> void: d["pad"][0]["at_ms"] = 100,
func(d: Dictionary) -> void: d["pad"][0]["ms"] = 0,
func(d: Dictionary) -> void: d["pad"][0]["min_ms"] = 50,
func(d: Dictionary) -> void: d["pad"][0]["volume"] = 1,
]
for k in cases.size():
var d := _valid().duplicate(true)
cases[k].call(d)
assert_gt(HapticPatterns.problems(d).size(), 0, "case %d must be rejected" % k)
func test_scaled_ahap_scales_and_clamps() -> void:
var ahap: Dictionary = JSON.parse_string(HapticPatterns.scaled_ahap("hit", 0.5, 2.0))
var params: Array = ahap["Pattern"][0]["Event"]["EventParameters"]
for p: Dictionary in params:
if String(p["ParameterID"]) == "HapticIntensity":
assert_almost_eq(float(p["ParameterValue"]), 0.5, 0.0001)
else:
assert_almost_eq(float(p["ParameterValue"]), 1.0, 0.0001, "0.7 x 2 clamps to 1")
func test_scaled_ahap_leaves_the_source_untouched() -> void:
HapticPatterns.scaled_ahap("decoy", 0.1, 0.1)
var again: Dictionary = JSON.parse_string(HapticPatterns.scaled_ahap("decoy", 1.0, 1.0))
assert_almost_eq(float(again["Pattern"][0]["Event"]["EventParameters"][0]["ParameterValue"]), 0.5, 0.0001)
func test_scaled_ahap_of_an_unknown_id_is_empty() -> void:
assert_eq(HapticPatterns.scaled_ahap("nope", 1.0, 1.0), "")
assert_eq(HapticPatterns.pad("nope"), [])
func test_level_up_pad_is_two_taps_80_ms_apart() -> void:
var p := HapticPatterns.pad("level_up")
assert_eq(p.size(), 2)
assert_eq(int(p[1]["at_ms"]) - int(p[0]["at_ms"]), 80)
  • Step 2: Run to verify failure

Run: scripts/check-test-count.sh Expected: FAIL (HapticPatterns not defined; parse error cascades into this file only).

  • Step 3: Write input/haptic_patterns.gd
class_name HapticPatterns
extends RefCounted
# Haptic patterns as data (spec docs/superpowers/specs/2026-10-06-corehaptics-premium-design.md §2).
# One file per id, res://data/haptics/<id>.json:
# "ahap": an Apple AHAP dictionary, played by the iOS DcHaptics extension on a Taptic iPhone.
# "pad": the two-motor fallback (every pad, Android phones, iPhone without the extension):
# pulses {weak, strong, ms, at_ms[, min_ms]}. weak/strong 0..1 are the motors at scale 1;
# at_ms is the offset from the play. A pulse WITH min_ms lasts max(min_ms, ms x scale);
# without it, ms is fixed.
# Loaded once, lazily. tests/test_haptic_patterns.gd validates every file, so a malformed pattern
# fails the gate, never a device. Render/input-side only.
const DIR := "res://data/haptics/"
const IDS: Array[String] = ["hit", "hit_heavy", "down", "level_up", "evolve", "leech", "heartbeat",
"boss_windup", "boss_kill", "nuke", "warp", "decoy", "warp_ready"]
const _EVENT_KEYS: Array[String] = ["Time", "EventType", "EventDuration", "EventParameters"]
const _EVENT_PARAMS: Array[String] = ["HapticIntensity", "HapticSharpness"]
const _CURVE_IDS: Array[String] = ["HapticIntensityControl", "HapticSharpnessControl"]
const _CURVE_KEYS: Array[String] = ["ParameterID", "Time", "ParameterCurveControlPoints"]
const _PAD_KEYS: Array[String] = ["weak", "strong", "ms", "at_ms", "min_ms"]
const MAX_CONTINUOUS_S := 30.0 # CoreHaptics' cap on one continuous event
static var _db: Dictionary = {} # id -> parsed file
static func load_file(id: String) -> Dictionary:
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(DIR + id + ".json"))
if parsed is Dictionary:
return parsed
return {}
static func _entry(id: String) -> Dictionary:
if _db.is_empty():
for i: String in IDS:
_db[i] = load_file(i)
var e: Variant = _db.get(id, {})
return e
static func pad(id: String) -> Array:
var p: Variant = _entry(id).get("pad", [])
if p is Array:
return p
return []
# The pattern's AHAP with every event's HapticIntensity x intensity and HapticSharpness x sharpness,
# clamped to 0..1, as the JSON string DcHaptics.play_ahap takes. The scales are snapped to 0.01 so
# the extension's parsed-pattern cache (keyed by this string) keeps hitting. "" for an unknown id.
static func scaled_ahap(id: String, intensity: float, sharpness: float) -> String:
var src: Variant = _entry(id).get("ahap", null)
if not (src is Dictionary):
return ""
var ahap: Dictionary = (src as Dictionary).duplicate(true)
var i := snappedf(intensity, 0.01)
var s := snappedf(sharpness, 0.01)
var pattern: Array = ahap["Pattern"]
for el: Variant in pattern:
var ev: Variant = (el as Dictionary).get("Event", null)
if not (ev is Dictionary):
continue
var params: Array = (ev as Dictionary).get("EventParameters", [])
for pv: Variant in params:
var p: Dictionary = pv
var v := float(p["ParameterValue"])
if String(p["ParameterID"]) == "HapticIntensity":
p["ParameterValue"] = clampf(v * i, 0.0, 1.0)
else:
p["ParameterValue"] = clampf(v * s, 0.0, 1.0)
return JSON.stringify(ahap)
# Every rule a pattern file must meet; [] = valid.
static func problems(d: Dictionary) -> Array[String]:
var out: Array[String] = []
if d.size() != 2 or not d.has("ahap") or not d.has("pad"):
out.append("top level must be exactly {ahap, pad}")
return out
var ahap: Variant = d["ahap"]
if not (ahap is Dictionary) or float((ahap as Dictionary).get("Version", 0.0)) != 1.0:
out.append("ahap.Version must be 1.0")
return out
var pattern: Variant = (ahap as Dictionary).get("Pattern", null)
if not (pattern is Array) or (pattern as Array).is_empty():
out.append("ahap.Pattern must be a non-empty array")
return out
for el: Variant in pattern:
out.append_array(_element_problems(el))
out.append_array(_pad_problems(d["pad"]))
return out
static func _in01(v: Variant) -> bool:
return (v is float or v is int) and float(v) >= 0.0 and float(v) <= 1.0
static func _element_problems(el: Variant) -> Array[String]:
var out: Array[String] = []
if not (el is Dictionary) or (el as Dictionary).size() != 1:
out.append("each Pattern element is {Event} or {ParameterCurve}")
return out
var e: Dictionary = el
if e.has("Event") and e["Event"] is Dictionary:
out.append_array(_event_problems(e["Event"]))
elif e.has("ParameterCurve") and e["ParameterCurve"] is Dictionary:
out.append_array(_curve_problems(e["ParameterCurve"]))
else:
out.append("each Pattern element is {Event} or {ParameterCurve}")
return out
static func _event_problems(ev: Dictionary) -> Array[String]:
var out: Array[String] = []
for k: Variant in ev.keys():
if not _EVENT_KEYS.has(String(k)):
out.append("unknown Event key " + String(k))
var t := String(ev.get("EventType", ""))
if t != "HapticTransient" and t != "HapticContinuous":
out.append("EventType must be HapticTransient or HapticContinuous")
if float(ev.get("Time", -1.0)) < 0.0:
out.append("Event.Time must be >= 0")
if t == "HapticContinuous":
var dur := float(ev.get("EventDuration", 0.0))
if dur <= 0.0 or dur > MAX_CONTINUOUS_S:
out.append("HapticContinuous needs EventDuration in (0, 30]")
elif ev.has("EventDuration"):
out.append("HapticTransient takes no EventDuration")
var params: Variant = ev.get("EventParameters", null)
if not (params is Array) or (params as Array).is_empty():
out.append("EventParameters must be a non-empty array")
return out
for pv: Variant in params:
if not (pv is Dictionary):
out.append("EventParameters entries are objects")
continue
var p: Dictionary = pv
var pid := String(p.get("ParameterID", ""))
if not _EVENT_PARAMS.has(pid):
out.append("unknown ParameterID " + pid)
if not _in01(p.get("ParameterValue", null)):
out.append(pid + " value must be in 0..1")
return out
static func _curve_problems(c: Dictionary) -> Array[String]:
var out: Array[String] = []
for k: Variant in c.keys():
if not _CURVE_KEYS.has(String(k)):
out.append("unknown ParameterCurve key " + String(k))
if not _CURVE_IDS.has(String(c.get("ParameterID", ""))):
out.append("ParameterCurve.ParameterID must be HapticIntensityControl or HapticSharpnessControl")
if float(c.get("Time", -1.0)) < 0.0:
out.append("ParameterCurve.Time must be >= 0")
var pts: Variant = c.get("ParameterCurveControlPoints", null)
if not (pts is Array) or (pts as Array).is_empty():
out.append("ParameterCurveControlPoints must be a non-empty array")
return out
var last := 0.0
for qv: Variant in pts:
var q: Dictionary = qv if qv is Dictionary else {}
var t := float(q.get("Time", -1.0))
if t < last:
out.append("control point Time must be >= 0 and ascending")
last = maxf(last, t)
if not _in01(q.get("ParameterValue", null)):
out.append("control point value must be in 0..1")
return out
static func _pad_problems(pad_v: Variant) -> Array[String]:
var out: Array[String] = []
if not (pad_v is Array) or (pad_v as Array).is_empty():
out.append("pad must be a non-empty array")
return out
var last_at := 0.0
for pv: Variant in pad_v:
if not (pv is Dictionary):
out.append("pad pulses are objects")
continue
var p: Dictionary = pv
for k: Variant in p.keys():
if not _PAD_KEYS.has(String(k)):
out.append("unknown pad key " + String(k))
for k: String in ["weak", "strong", "ms", "at_ms"]:
if not p.has(k):
out.append("pad pulse needs " + k)
if not (_in01(p.get("weak", null)) and _in01(p.get("strong", null))):
out.append("pad weak/strong must be in 0..1")
var ms := float(p.get("ms", 0.0))
if ms < 1.0 or ms > 1000.0 or ms != floorf(ms):
out.append("pad ms must be a whole number 1..1000")
if p.has("min_ms") and (float(p["min_ms"]) < 1.0 or float(p["min_ms"]) > ms):
out.append("pad min_ms must be 1..ms")
var at := float(p.get("at_ms", -1.0))
if at < last_at:
out.append("pad at_ms must be >= 0 and ascending")
last_at = maxf(last_at, at)
return out
  • Step 4: Write the 13 pattern files

Values come from the spec’s §3 table (first drafts; tuned later in the Lab). The pad fallbacks reproduce today’s HapticsDirector pulses where one exists. One exception: the hit’s duration is now linear, max(40, 120 x scale), instead of today’s lerp(40, 120, (s - 0.25) / 0.75). The two agree at s = 0.25 and 1.0 but not in between (60 ms vs 67 ms at s = 0.5), which is within first-draft tuning (pre-flight F9).

data/haptics/hit.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.7}]}}]},
"pad": [{"weak": 1.0, "strong": 0.5, "ms": 120, "min_ms": 40, "at_ms": 0}]}

data/haptics/hit_heavy.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.7}]}},
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.12, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.8}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.2}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.0, "ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 1.0}, {"Time": 0.12, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 1.0, "strong": 0.5, "ms": 120, "min_ms": 40, "at_ms": 0}]}

data/haptics/down.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.3}]}},
{"Event": {"Time": 0.02, "EventType": "HapticContinuous", "EventDuration": 0.6, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.8}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.2}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.02, "ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 1.0}, {"Time": 0.6, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 1.0, "strong": 1.0, "ms": 400, "at_ms": 0}]}

data/haptics/level_up.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.6}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.9}]}},
{"Event": {"Time": 0.08, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.6}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.9}]}}]},
"pad": [{"weak": 0.6, "strong": 0.0, "ms": 30, "at_ms": 0}, {"weak": 0.6, "strong": 0.0, "ms": 30, "at_ms": 80}]}

data/haptics/evolve.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.4}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.5}]}},
{"Event": {"Time": 0.07, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.6}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.75}]}},
{"Event": {"Time": 0.14, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.8}, {"ParameterID": "HapticSharpness", "ParameterValue": 1.0}]}},
{"Event": {"Time": 0.21, "EventType": "HapticContinuous", "EventDuration": 0.3, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.7}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.5}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.21, "ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 0.3}, {"Time": 0.2, "ParameterValue": 1.0}, {"Time": 0.3, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 0.3, "strong": 0.0, "ms": 40, "at_ms": 0}, {"weak": 0.5, "strong": 0.0, "ms": 40, "at_ms": 70}, {"weak": 0.7, "strong": 0.0, "ms": 40, "at_ms": 140}, {"weak": 0.6, "strong": 1.0, "ms": 300, "at_ms": 210}]}

data/haptics/leech.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.35}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.1}]}}]},
"pad": [{"weak": 0.25, "strong": 0.0, "ms": 25, "at_ms": 0}]}

data/haptics/heartbeat.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.5}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.2}]}},
{"Event": {"Time": 0.14, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.35}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.2}]}}]},
"pad": [{"weak": 0.0, "strong": 0.4, "ms": 30, "at_ms": 0}, {"weak": 0.0, "strong": 0.25, "ms": 30, "at_ms": 140}]}

data/haptics/boss_windup.json (the director scales intensity with the charge, and on iPhone the sharpness too):

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.12, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.5}]}}]},
"pad": [{"weak": 1.0, "strong": 0.0, "ms": 120, "at_ms": 0}]}

data/haptics/boss_kill.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 1.0}]}},
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.9, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.4}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.0, "ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 1.0}, {"Time": 0.9, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 1.0, "strong": 1.0, "ms": 300, "at_ms": 0}]}

data/haptics/nuke.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 1.0}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.15}]}},
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.25, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.6}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.1}]}}]},
"pad": [{"weak": 0.6, "strong": 0.9, "ms": 250, "at_ms": 0}]}

data/haptics/warp.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticContinuous", "EventDuration": 0.08, "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.8}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.8}]}},
{"ParameterCurve": {"ParameterID": "HapticIntensityControl", "Time": 0.0, "ParameterCurveControlPoints": [{"Time": 0.0, "ParameterValue": 1.0}, {"Time": 0.08, "ParameterValue": 0.0}]}}]},
"pad": [{"weak": 0.7, "strong": 0.0, "ms": 18, "at_ms": 0}]}

data/haptics/decoy.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.5}, {"ParameterID": "HapticSharpness", "ParameterValue": 0.8}]}}]},
"pad": [{"weak": 0.55, "strong": 0.0, "ms": 18, "at_ms": 0}]}

data/haptics/warp_ready.json:

{"ahap": {"Version": 1.0, "Pattern": [
{"Event": {"Time": 0.0, "EventType": "HapticTransient", "EventParameters": [{"ParameterID": "HapticIntensity", "ParameterValue": 0.25}, {"ParameterID": "HapticSharpness", "ParameterValue": 1.0}]}}]},
"pad": [{"weak": 0.15, "strong": 0.0, "ms": 15, "at_ms": 0}]}
  • Step 5: Run to verify pass

Run: godot --headless --path . --import > /dev/null 2>&1; scripts/check-test-count.sh Expected: PASS.

  • Step 6: Gate and commit

scripts/gate.sh green; git add input/haptic_patterns.gd input/haptic_patterns.gd.uid data/haptics tests/test_haptic_patterns.gd tests/test_haptic_patterns.gd.uid; commit “feat(haptics): patterns as data (AHAP + pad fallback) with a validator”.


Task 3: HapticsOutput.play(): pad scheduler, native routing, gains

Section titled “Task 3: HapticsOutput.play(): pad scheduler, native routing, gains”

Files:

  • Modify: input/haptics_output.gd, tests/haptics_spy.gd
  • Test: tests/test_haptics_output.gd

Interfaces:

  • Consumes: HapticPatterns.pad(id), HapticPatterns.scaled_ahap(id, i, s) (Task 2); HapticsOutput.native (Task 1).

  • Produces:

    • func play(pilot: int, id: String, scale: float = 1.0, handheld_ok: bool = true, sharp: float = 1.0) -> void
    • func flush(now: int) -> void (sets now_ms, sends every due pad pulse; drops them while disabled)
    • var now_ms: int
    • static var last_path: String ("native", "pad", "none", "" before any play)
    • static func id_gain(id: String) -> float
    • static gains (all float, default 1.0): INTENSITY, SHARPNESS, HIT_INTENSITY, HIT_HEAVY_INTENSITY, DOWN_INTENSITY, LEVEL_UP_INTENSITY, EVOLVE_INTENSITY, LEECH_INTENSITY, HEARTBEAT_INTENSITY, BOSS_WINDUP_INTENSITY, BOSS_KILL_INTENSITY, NUKE_INTENSITY, WARP_INTENSITY, DECOY_INTENSITY, WARP_READY_INTENSITY
    • HapticsSpy.record_plays: bool (default false) and HapticsSpy.plays(id := "") -> Array of {"kind": "play", "pilot", "id", "scale", "handheld_ok", "sharp"}
  • pulse() stays until Task 4 removes it.

  • Step 1: Write the failing tests

Add to tests/test_haptics_output.gd (extend after_each with HapticsOutput.native = null, HapticsOutput.INTENSITY = 1.0, HapticsOutput.SHARPNESS = 1.0, HapticsOutput.LEVEL_UP_INTENSITY = 1.0, HapticsOutput.last_path = ""):

func _spy(routes: Array) -> HapticsSpy:
var s := HapticsSpy.new()
s.routes = routes
s.flush(1000)
return s
func test_play_sends_the_patterns_pad_pulse() -> void:
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "down")
assert_eq(spy.calls, [{"kind": "joy", "device": 4, "weak": 1.0, "strong": 1.0, "ms": 400}])
assert_eq(HapticsOutput.last_path, "pad")
func test_multi_pulse_patterns_are_scheduled() -> void:
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "level_up")
assert_eq(spy.of_kind("joy").size(), 1, "first tap now")
spy.flush(1079)
assert_eq(spy.of_kind("joy").size(), 1, "second not due")
spy.flush(1080)
assert_eq(spy.of_kind("joy").size(), 2, "second tap 80 ms later")
spy.flush(2000)
assert_eq(spy.of_kind("joy").size(), 2, "exactly two")
func test_scale_sets_motor_strength_and_min_ms_duration() -> void:
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "hit", 0.25)
assert_almost_eq(float(spy.calls[0]["weak"]), 0.25, 0.001)
assert_almost_eq(float(spy.calls[0]["strong"]), 0.125, 0.001)
assert_eq(int(spy.calls[0]["ms"]), 40, "max(min_ms 40, 120 x 0.25)")
spy.play(0, "hit", 1.0)
assert_eq(int(spy.calls[1]["ms"]), 120)
func test_handheld_without_native_vibrates_the_pad_pulses() -> void:
var spy := _spy([{"joypads": [], "handheld": true}])
spy.play(0, "hit", 0.25)
assert_eq(spy.calls, [{"kind": "handheld", "ms": 40, "amp": 0.25}])
func test_handheld_with_native_plays_the_ahap_not_the_vibrator() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := _spy([{"joypads": [], "handheld": true}])
spy.play(0, "hit", 0.5)
assert_eq(spy.of_kind("handheld").size(), 0)
var a := fake.of_kind("ahap")
assert_eq(a.size(), 1)
var params: Array = a[0]["ahap"]["Pattern"][0]["Event"]["EventParameters"]
assert_almost_eq(float(params[0]["ParameterValue"]), 0.5, 0.001, "hit intensity 1.0 x scale 0.5")
assert_eq(HapticsOutput.last_path, "native")
func test_pads_never_take_the_native_path() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "decoy")
assert_eq(fake.of_kind("ahap").size(), 0)
assert_eq(spy.of_kind("joy").size(), 1)
func test_handheld_ok_false_skips_native_and_vibrator() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := _spy([{"joypads": [4], "handheld": true}])
spy.play(0, "warp", 1.0, false)
assert_eq(fake.of_kind("ahap").size(), 0)
assert_eq(spy.of_kind("handheld").size(), 0)
assert_eq(spy.of_kind("joy").size(), 1, "the pad still gets it")
func test_gains_scale_pads_and_ahap_and_clamp() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
HapticsOutput.INTENSITY = 0.5
var spy := _spy([{"joypads": [4], "handheld": false}, {"joypads": [], "handheld": true}])
spy.play(0, "down")
assert_almost_eq(float(spy.calls[0]["weak"]), 0.5, 0.001)
HapticsOutput.INTENSITY = 1.0
HapticsOutput.LEVEL_UP_INTENSITY = 1.5
HapticsOutput.SHARPNESS = 0.5
spy.play(1, "level_up")
var params: Array = fake.of_kind("ahap")[0]["ahap"]["Pattern"][0]["Event"]["EventParameters"]
assert_almost_eq(float(params[0]["ParameterValue"]), 0.9, 0.001, "0.6 x per-id 1.5")
assert_almost_eq(float(params[1]["ParameterValue"]), 0.45, 0.001, "0.9 x sharpness 0.5")
HapticsOutput.INTENSITY = 3.0
spy.play(0, "down")
assert_almost_eq(float(spy.of_kind("joy")[1]["weak"]), 1.0, 0.001, "clamped to 1")
func test_sharp_argument_scales_ahap_sharpness() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := _spy([{"joypads": [], "handheld": true}])
spy.play(0, "boss_windup", 0.5, true, 0.4)
var params: Array = fake.of_kind("ahap")[0]["ahap"]["Pattern"][0]["Event"]["EventParameters"]
assert_almost_eq(float(params[1]["ParameterValue"]), 0.2, 0.001, "0.5 x 0.4")
func test_stop_all_drops_scheduled_pulses() -> void:
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "level_up")
spy.stop_all()
spy.flush(1200)
assert_eq(spy.of_kind("joy").size(), 1, "the second tap died with the stop")
func test_disabled_play_and_flush_send_nothing() -> void:
var spy := _spy([{"joypads": [4], "handheld": true}])
spy.play(0, "level_up")
HapticsOutput.enabled = false
spy.flush(1200)
spy.play(0, "down")
assert_eq(spy.of_kind("joy").size(), 1, "only the tap played before the switch")
assert_eq(HapticsOutput.last_path, "none")
func test_play_out_of_range_pilot_or_keyboard_is_a_no_op() -> void:
var spy := _spy([{"joypads": [], "handheld": false}])
spy.play(0, "down")
spy.play(1, "down")
spy.play(-1, "down")
assert_eq(spy.calls.size(), 0)
assert_eq(HapticsOutput.last_path, "none")
func test_every_pattern_has_a_gain() -> void:
# Set each gain through the script (the same path Tunables uses), so a missing match arm in
# id_gain() fails here instead of hiding behind its 1.0 fallback (pre-flight F7).
var s: GDScript = load("res://input/haptics_output.gd")
for id: String in HapticPatterns.IDS:
var gain_key := "%s_INTENSITY" % id.to_upper()
s.set(gain_key, 1.7)
assert_almost_eq(HapticsOutput.id_gain(id), 1.7, 0.0001, id)
s.set(gain_key, 1.0)
assert_eq(HapticsOutput.id_gain("nope"), 1.0)
func test_spy_records_plays_only_when_asked() -> void:
var spy := _spy([{"joypads": [4], "handheld": false}])
spy.play(0, "decoy")
assert_eq(spy.plays().size(), 0)
spy.record_plays = true
spy.play(0, "decoy", 0.5, false)
assert_eq(spy.plays("decoy"), [{"kind": "play", "pilot": 0, "id": "decoy", "scale": 0.5, "handheld_ok": false, "sharp": 1.0}])
  • Step 2: Run to verify failure

Run: scripts/check-test-count.sh → FAIL (play/flush not defined).

  • Step 3: Implement

In input/haptics_output.gd add, below the Task 1 native block:

# Which path the last play() took: "native", "pad" or "none" ("" before any). The Debug Settings
# Haptic Lab row shows it.
static var last_path := ""
# Feel gains, live-tunable from the Remote Console (net/tunables_registry.gd). INTENSITY scales every
# pattern (pad motors too), SHARPNESS every AHAP sharpness, <ID>_INTENSITY one pattern. Tune live,
# then bake the result into data/haptics/<id>.json and put the per-id gain back to 1.
static var INTENSITY: float = 1.0
static var SHARPNESS: float = 1.0
static var HIT_INTENSITY: float = 1.0
static var HIT_HEAVY_INTENSITY: float = 1.0
static var DOWN_INTENSITY: float = 1.0
static var LEVEL_UP_INTENSITY: float = 1.0
static var EVOLVE_INTENSITY: float = 1.0
static var LEECH_INTENSITY: float = 1.0
static var HEARTBEAT_INTENSITY: float = 1.0
static var BOSS_WINDUP_INTENSITY: float = 1.0
static var BOSS_KILL_INTENSITY: float = 1.0
static var NUKE_INTENSITY: float = 1.0
static var WARP_INTENSITY: float = 1.0
static var DECOY_INTENSITY: float = 1.0
static var WARP_READY_INTENSITY: float = 1.0

and below var routes: Array = []:

var now_ms: int = 0 # the director's clock, set by flush(); pad pulses are scheduled against it
var _pending: Array[Dictionary] = [] # {"at", "device" (-1 = the handheld), "weak", "strong", "ms"}, by "at"

Add these functions (keep pulse() for now):

static func id_gain(id: String) -> float:
match id:
"hit": return HIT_INTENSITY
"hit_heavy": return HIT_HEAVY_INTENSITY
"down": return DOWN_INTENSITY
"level_up": return LEVEL_UP_INTENSITY
"evolve": return EVOLVE_INTENSITY
"leech": return LEECH_INTENSITY
"heartbeat": return HEARTBEAT_INTENSITY
"boss_windup": return BOSS_WINDUP_INTENSITY
"boss_kill": return BOSS_KILL_INTENSITY
"nuke": return NUKE_INTENSITY
"warp": return WARP_INTENSITY
"decoy": return DECOY_INTENSITY
"warp_ready": return WARP_READY_INTENSITY
return 1.0
# One pattern for one pilot. scale: the event's strength (hit damage, wind-up charge); it multiplies
# intensity, and the duration of pad pulses that carry min_ms. sharp: an extra sharpness scale
# (iPhone only). handheld_ok = false for pad-only cues the touch UI already shows (warp_ready).
# The handheld route on a Taptic iPhone gets the AHAP; pads (and a handheld without the extension)
# get the pad pulses, the first at once and the rest from flush().
func play(pilot: int, id: String, scale: float = 1.0, handheld_ok: bool = true, sharp: float = 1.0) -> void:
last_path = "none"
if not enabled or pilot < 0 or pilot >= routes.size():
return
var route: Dictionary = routes[pilot]
var pads: Array = route.get("joypads", [])
var hand := handheld_ok and bool(route.get("handheld", false))
var gain := scale * INTENSITY * id_gain(id)
if hand and native != null:
native.call("play_ahap", HapticPatterns.scaled_ahap(id, gain, sharp * SHARPNESS))
last_path = "native"
hand = false
if pads.is_empty() and not hand:
return
for pv: Variant in HapticPatterns.pad(id):
var p: Dictionary = pv
var w := clampf(float(p["weak"]) * gain, 0.0, 1.0)
var s := clampf(float(p["strong"]) * gain, 0.0, 1.0)
var ms := int(p["ms"])
if p.has("min_ms"):
ms = maxi(int(p["min_ms"]), roundi(float(ms) * scale))
var at := now_ms + int(p["at_ms"])
for d: Variant in pads:
_schedule(at, int(d), w, s, ms)
if hand:
_schedule(at, -1, w, s, ms)
if last_path == "none":
last_path = "pad"
# Once per frame (HapticsDirector.update): advance the clock and send every pulse now due.
func flush(now: int) -> void:
now_ms = now
if not enabled:
_pending.clear()
return
while not _pending.is_empty() and int(_pending[0]["at"]) <= now_ms:
_send(_pending.pop_front())
func _schedule(at: int, device: int, weak: float, strong: float, ms: int) -> void:
var p := {"at": at, "device": device, "weak": weak, "strong": strong, "ms": ms}
if at <= now_ms:
_send(p)
return
var k := _pending.size()
while k > 0 and int(_pending[k - 1]["at"]) > at:
k -= 1
_pending.insert(k, p)
func _send(p: Dictionary) -> void:
var w := float(p["weak"])
var s := float(p["strong"])
if int(p["device"]) < 0:
_handheld(int(p["ms"]), maxf(w, s))
else:
_joy(int(p["device"]), w, s, float(p["ms"]) / 1000.0)

and make stop_all() start with _pending.clear(). Update the file’s header comment: one paragraph on play() (AHAP to a Taptic handheld, pad pulses elsewhere, scheduled against now_ms).

In tests/haptics_spy.gd add:

var record_plays := false # director tests: also record which pattern ids were played
func play(pilot: int, id: String, scale: float = 1.0, handheld_ok: bool = true, sharp: float = 1.0) -> void:
if record_plays:
calls.append({"kind": "play", "pilot": pilot, "id": id, "scale": scale, "handheld_ok": handheld_ok, "sharp": sharp})
super.play(pilot, id, scale, handheld_ok, sharp)
# The recorded plays, optionally of one pattern id only (needs record_plays).
func plays(id: String = "") -> Array:
var out: Array = []
for c: Dictionary in calls:
if String(c["kind"]) == "play" and (id == "" or String(c["id"]) == id):
out.append(c)
return out
  • Step 4: Run to verify pass

Run: scripts/check-test-count.sh → PASS.

  • Step 5: Gate and commit

scripts/gate.sh green; commit input/haptics_output.gd tests/haptics_spy.gd tests/test_haptics_output.gd: “feat(haptics): HapticsOutput.play with pattern data, pad scheduler and native routing”.


Task 4: Director emits pattern ids and owns the warp/decoy buzz; pulse() goes

Section titled “Task 4: Director emits pattern ids and owns the warp/decoy buzz; pulse() goes”

Files:

  • Modify: input/haptics_director.gd, input/haptics_output.gd (delete pulse()), ui/touch_controls.gd:176-200
  • Test: tests/test_haptics_director.gd, tests/test_haptics_output.gd, tests/test_haptics_main.gd (line 56 names HapticsDirector.BOSS_KILL_MS, which this task deletes)

Interfaces:

  • Consumes: HapticsOutput.play/flush (Task 3).

  • Produces: HapticsDirector consts HIT_SCALE, HIT_MIN, HIT_HEAVY_FRAC (0.10), HIT_COOLDOWN_MS, HIT_FLOOR_FRAC, WIND_BASE, WIND_SPAN, WIND_SHARP_BASE (0.4), WIND_SHARP_SPAN (1.2), WIND_EVERY_MS, DASH_EDGE, DECOY_SPENT. Removed: HIT_MIN_MS, HIT_MAX_MS, HIT_STRONG_RATIO, DOWN_MS, LEVEL_TAP_*, WIND_MS, BOSS_KILL_MS, ABILITY_MS, WARP_WEAK, DECOY_WEAK (their numbers now live in data/haptics/*.json) and _queued/_flush (the output schedules multi-pulse patterns).

  • Step 1: Update the tests first

In tests/test_haptics_director.gd:

  • test_killing_frame_clears_the_accumulator: replace HapticsDirector.DOWN_MS with 400. (Task 6 rewrites this test’s assertions again once the heartbeat exists.)
  • In tests/test_haptics_main.gd (test_process_forwards_boss_down_from_render_events, line 56): replace HapticsDirector.BOSS_KILL_MS with 300 (the boss_kill pad ms). Without this the whole file fails to parse once the const is gone (pre-flight F2).
  • Replace test_warp_and_decoy_edges_rumble_pads_but_never_the_phone (it pins the old “the buttons buzz themselves” split) with:
func test_warp_and_decoy_reach_pads_and_the_phone() -> void:
# The haptics system owns these (Chris, 2026-10-06); TouchControls no longer buzzes for them.
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)
assert_eq(spy.of_kind("handheld").size(), 1, "the phone gets the warp too")
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(), 2, "and the decoy")
func test_warp_and_decoy_play_the_ahap_on_a_taptic_iphone() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
spy.routes = [{"joypads": [], "handheld": true}]
var p := _pilot()
p.drone_charge = 1.0
d.update([p], 0, false, false)
p.dash_cd = 2.0
p.drone_charge = 0.0
d.update([p], 16, false, false)
assert_eq(fake.of_kind("ahap").size(), 2, "warp + decoy, native")
assert_eq(spy.of_kind("handheld").size(), 0, "no plain vibrate on top")
  • Add (they fail until Step 3):
func test_a_light_hit_plays_hit_and_a_heavy_one_hit_heavy() -> void:
spy.record_plays = true
var p := _pilot()
d.update([p], 0, false, false)
p.hp = 95.0
d.update([p], 16, false, false)
p.hp = 80.0 # 15% >= HIT_HEAVY_FRAC
d.update([p], 400, false, false)
assert_eq(spy.plays("hit").size(), 1)
assert_almost_eq(float(spy.plays("hit")[0]["scale"]), 0.25, 0.001)
assert_eq(spy.plays("hit_heavy").size(), 1)
assert_almost_eq(float(spy.plays("hit_heavy")[0]["scale"]), 0.6, 0.001, "15% x 4")
func test_events_map_to_pattern_ids() -> void:
spy.record_plays = true
var p := _pilot(30.0) # above the 25% heartbeat threshold (Task 6), so the id list stays exact
p.drone_charge = 1.0
d.update([p], 0, false, false)
p.pending_levelups = 1
p.dash_cd = 2.0
p.drone_charge = 0.0
d.update([p], 16, false, false)
d.note_events([{"kind": "reaction", "name": "BOSS DOWN"}])
p.hp = 0.0
d.update([p], 32, false, false)
var ids: Array = spy.plays().map(func(c: Dictionary) -> String: return String(c["id"]))
assert_eq(ids, ["level_up", "warp", "decoy", "down", "boss_kill"])
assert_true(bool(spy.plays("warp")[0]["handheld_ok"]), "the haptics system owns the phone buzz")
assert_true(bool(spy.plays("decoy")[0]["handheld_ok"]))
func test_boss_wind_up_scales_intensity_and_sharpness_with_the_charge() -> void:
spy.record_plays = true
var p := _pilot()
d.update([p], 0, false, false)
d.boss_charge = 0.5
d.update([p], 16, false, false)
var w: Dictionary = spy.plays("boss_windup")[0]
assert_almost_eq(float(w["scale"]), 0.325, 0.001)
assert_almost_eq(float(w["sharp"]), 1.0, 0.001, "0.4 + 1.2 x 0.5")
func test_iphone_route_plays_the_ahap() -> void:
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
spy.routes = [{"joypads": [], "handheld": true}]
var p := _pilot()
d.update([p], 0, false, false)
p.pending_levelups = 1
d.update([p], 16, false, false)
d.update([p], 200, false, false)
assert_eq(fake.of_kind("ahap").size(), 1, "the AHAP carries both taps; no scheduled second tap")
assert_eq(spy.of_kind("handheld").size(), 0)

and add HapticsOutput.native = null to after_each.

In tests/test_haptics_output.gd delete the six tests that call pulse() (test_keyboard_only_is_a_no_op, test_pulse_routes_to_joypad_and_handheld, test_handheld_ok_false_skips_the_phone_but_not_the_pad, test_values_are_clamped, test_disabled_sends_nothing, test_out_of_range_pilot_is_ignored); Task 3’s play-based tests cover each.

  • Step 2: Run to verify failure

Run: scripts/check-test-count.sh → the 6 new or replaced director tests FAIL (the two warp/decoy tests plus the four added above).

  • Step 3: Rewrite input/haptics_director.gd

Replace the pattern-constants block with:

# ── Event tuning. The FEEL of each pattern (motor strengths, durations, AHAP curves) is data in
# data/haptics/<id>.json; live gains are HapticsOutput.*_INTENSITY. What stays here is WHEN a
# pattern fires and how hard the event was. ──
const HIT_SCALE := 4.0 # hit scale = damage / max_hp x HIT_SCALE ...
const HIT_MIN := 0.25 # ... clamped to [HIT_MIN, 1.0]
const HIT_HEAVY_FRAC := 0.10 # a hit of at least this x max_hp plays "hit_heavy" (adds a rumble tail)
const HIT_COOLDOWN_MS := 150 # per pilot, so a contact swarm can't buzz continuously
const HIT_FLOOR_FRAC := 0.02 # hp lost accumulates until it reaches this x max_hp, so a damage-
# over-time trickle (burn, hazard zones) can't buzz every cooldown
const WIND_BASE := 0.15 # boss wind-up scale = WIND_BASE + WIND_SPAN x charge
const WIND_SPAN := 0.35
const WIND_SHARP_BASE := 0.4 # ... and (iPhone) sharpness = WIND_SHARP_BASE + WIND_SHARP_SPAN x charge
const WIND_SHARP_SPAN := 1.2
const WIND_EVERY_MS := 100 # re-armed this often while the charge rises (the pattern lasts 120 ms)
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

Delete var _queued, its clear() in reset(), and _flush(). In update(), make the first line output.flush(now_ms) and drop the final _flush(now_ms, live) call. Replace each output.pulse(...):

# update(): boss kill
for i in pilots.size():
output.play(i, "boss_kill")
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.play(i, "down")
_hit_acc[i] = 0.0
elif was_hp > 0.0 and p.hp < was_hp:
_hit_acc[i] += was_hp - p.hp
var max_hp := maxf(p.max_hp, 1.0)
# Small epsilon: a trickle of float slivers summing to exactly the floor still counts.
if now_ms >= _hit_ok_ms[i] and _hit_acc[i] >= HIT_FLOOR_FRAC * max_hp - 0.0001:
var frac := _hit_acc[i] / max_hp
var s := clampf(frac * HIT_SCALE, HIT_MIN, 1.0)
output.play(i, "hit_heavy" if frac >= HIT_HEAVY_FRAC else "hit", s)
_hit_ok_ms[i] = now_ms + HIT_COOLDOWN_MS
_hit_acc[i] = 0.0
if p.pending_levelups > _levels[i]:
output.play(i, "level_up")
if _dash[i] <= 0.0 and p.dash_cd > DASH_EDGE:
output.play(i, "warp") # phone included: TouchControls no longer buzzes for WARP/DRONE
if _charge[i] >= 1.0 and p.drone_charge < DECOY_SPENT:
output.play(i, "decoy")
# _boss_wind(): replace the pulse loop
var c := clampf(boss_charge, 0.0, 1.0)
for i in pilot_count:
output.play(i, "boss_windup", WIND_BASE + WIND_SPAN * c, true, WIND_SHARP_BASE + WIND_SHARP_SPAN * c)

Update the header comment’s “Patterns” line to point at data/haptics/.

In input/haptics_output.gd delete pulse().

In ui/touch_controls.gd delete the _haptic(18, 0.7) line from fire_dash() and the _haptic(18, 0.55) line from fire_decoy(), and put one comment above fire_dash: # WARP / DRONE buzz: HapticsDirector plays "warp"/"decoy" when the sim confirms them (one owner, so the Haptic Lab tuning is what plays). fire_bay keeps _haptic(30, 0.5) (a UI tap, not a gameplay event). (Checked while planning: main._drive_haptics passes Platform.is_touch() and not Platform.force_touch, and routes_for gives pilot 1 handheld: true only when no pad is playable, the same case the buttons buzzed for. Also update the stale handheld_ok comment above routes_for/pulse in haptics_output.gd.)

  • Step 4: Run to verify pass

Run: scripts/check-test-count.sh → PASS, including every pre-existing director test unchanged except the DOWN_MS literal. Their pad numbers match today’s at the values those tests check (hit scale 0.25 and 1.0). In between, the hit’s duration is linear, not lerped; see Task 2 Step 4. Then git grep -n "\.pulse(" -- '*.gd' → no output.

  • Step 5: Gate and commit

scripts/gate.sh green; commit “refactor(haptics): director plays pattern ids; touch warp/decoy taps go native”.


Task 5: Sim: PlayerState.leech_healed, and the leech pattern

Section titled “Task 5: Sim: PlayerState.leech_healed, and the leech pattern”

Files:

  • Modify: sim/player_state.gd (after drone_charge, line ~25), sim/sim.gd:2850-2854, input/haptics_director.gd
  • Test: tests/test_leech.gd, tests/test_haptics_director.gd

Interfaces:

  • Produces: PlayerState.leech_healed: float (monotonic per run, render-read only); HapticsDirector.LEECH_FRAC := 0.03, HapticsDirector.LEECH_EVERY_MS := 400.

  • Step 1: Write the failing sim tests

Append to tests/test_leech.gd:

# CoreHaptics plan Task 5: the haptics director needs to tell Leech heal from regen, so the HP Leech
# actually restores is counted per pilot. Render-read only: never in the snapshot or checksum.
func test_leech_heal_is_counted_on_the_pilot() -> void:
var sim := _sim()
sim.mods.lifesteal = 0.04
sim.player.max_hp = 100.0
sim.player.hp = 50.0
_hit(sim, 2.0)
sim._sweep_dead()
assert_almost_eq(sim.player.leech_healed, 0.08, 0.0001, "the HP actually gained")
_hit(sim, 2.0)
sim._sweep_dead()
assert_almost_eq(sim.player.leech_healed, 0.16, 0.0001, "it accumulates")
func test_a_full_hp_pilot_gains_nothing_to_count() -> void:
var sim := _sim()
sim.mods.lifesteal = 0.04
sim.player.hp = sim.player.max_hp
_hit(sim, 2.0)
sim._sweep_dead()
assert_eq(sim.player.leech_healed, 0.0, "clamped at max HP: nothing gained, nothing to feel")
func test_leech_healed_is_not_part_of_the_checksum() -> void:
var sim := _sim()
var snap := sim.snapshot_string()
var sum := sim.state_checksum()
sim.player.leech_healed = 123.0
assert_eq(sim.snapshot_string(), snap)
assert_eq(sim.state_checksum(), sum)
  • Step 2: Run to verify failure (leech_healed undefined): scripts/check-test-count.sh.

  • Step 3: Implement the sim change

sim/player_state.gd, after var drone_charge:

# HP Leech actually restored this run (after the max_hp clamp). Render-read only (HapticsDirector
# plays the "leech" pattern off it); deliberately NOT in snapshot_string / state_checksum.
var leech_healed: float = 0.0

sim/sim.gd, inside the mods.lifesteal block of _sweep_dead():

var heal := minf(lifesteal_dealt * mods.lifesteal, lp.max_hp * LEECH_CAP_FRAC_PER_S * Sim_Const.DT)
var before := lp.hp
lp.hp = minf(lp.hp + heal, lp.max_hp)
lp.leech_healed += lp.hp - before # render-read (haptics); not in the checksum
  • Step 4: Run scripts/check-test-count.sh → PASS, and confirm the three determinism files are among the passes (grep $TMPDIR/bh-gut-last.log for test_determinism).

  • Step 5: Write the failing director tests

Append to tests/test_haptics_director.gd:

func test_leech_plays_past_3_percent_and_at_most_every_400_ms() -> void:
spy.record_plays = true
var p := _pilot(50.0)
d.update([p], 0, false, false)
p.leech_healed = 2.0 # under 3% of 100
d.update([p], 16, false, false)
assert_eq(spy.plays("leech").size(), 0)
p.leech_healed = 3.5
d.update([p], 32, false, false)
assert_eq(spy.plays("leech").size(), 1, "3.5 HP accumulated")
p.leech_healed = 7.0
d.update([p], 200, false, false)
assert_eq(spy.plays("leech").size(), 1, "inside 400 ms")
d.update([p], 432, false, false)
assert_eq(spy.plays("leech").size(), 2, "the accumulated 3.5 plays once the window opens")
func test_regen_and_pickups_are_not_leech() -> void:
spy.record_plays = true
var p := _pilot(50.0)
d.update([p], 0, false, false)
p.hp = 80.0
d.update([p], 16, false, false)
assert_eq(spy.plays("leech").size(), 0)
func test_leech_through_the_real_kill_path() -> void:
spy.record_plays = true
var sim := Sim.new(4, SimContentFixture.db())
sim.mods.lifesteal = 1.0
sim.player.max_hp = 100.0
sim.player.hp = 50.0
d.update(sim.pilots, 0, false, false)
var t := 0
for k in 60: # the 8%/s cap pays ~0.13 HP a tick: 3 HP takes ~23 ticks
sim.enemies.count = 0
sim.enemies.add(Vector2(400, 0), Vector2.ZERO, 12.0, 1000.0)
sim.enemies.armor[0] = 0.0
sim.elemental_system.damage_enemy(sim, 0, 50.0)
sim._sweep_dead()
t += 16
d.update(sim.pilots, t, false, false)
assert_gt(spy.plays("leech").size(), 0, "real Leech heal reaches the director")
  • Step 6: Implement the director side

In input/haptics_director.gd add consts:

const LEECH_FRAC := 0.03 # Leech heal accumulates until it reaches this x max_hp ...
const LEECH_EVERY_MS := 400 # ... and plays at most this often (it pays a little every tick)

state var _leech: Array[float] = [], var _leech_acc: Array[float] = [], var _leech_ok_ms: Array[int] = [] (cleared in reset(); appended in the baseline branch as p.leech_healed, 0.0, 0; in the not-live branch also _leech_acc[i] = 0.0; after _charge[i] = p.drone_charge add _leech[i] = p.leech_healed). At the end of _pilot_events:

var healed := p.leech_healed - _leech[i]
if healed > 0.0:
_leech_acc[i] += healed
if _leech_acc[i] >= LEECH_FRAC * maxf(p.max_hp, 1.0) - 0.0001 and now_ms >= _leech_ok_ms[i]:
output.play(i, "leech")
_leech_acc[i] = 0.0
_leech_ok_ms[i] = now_ms + LEECH_EVERY_MS
  • Step 7: Run scripts/check-test-count.sh → PASS. Gate scripts/gate.sh (determinism green, no re-pin). Commit “feat(haptics): Leech heal is counted per pilot and plays the leech pattern”.

Task 6: Evolve, heartbeat, NUKE, warp-ready

Section titled “Task 6: Evolve, heartbeat, NUKE, warp-ready”

Files:

  • Modify: input/haptics_director.gd
  • Test: tests/test_haptics_director.gd

Interfaces:

  • Produces: static var HEARTBEAT_FRAC: float = 0.25, static var HEARTBEAT_EVERY_S: float = 1.1 (registered as tunables in Task 7). The signature becomes _pilot_events(i: int, p: PlayerState, now_ms: int, paused: bool, evolved: int): two new parameters, paused and evolved (the pilot’s evolved-weapon count, computed once per frame in update).

  • Step 1: Write the failing tests

In tests/test_haptics_director.gd, add to after_each: HapticsDirector.HEARTBEAT_FRAC = 0.25 and HapticsDirector.HEARTBEAT_EVERY_S = 1.1. The live-tunables test below changes them, and the restore must run even if the test errors (pre-flight F13a).

Rewrite test_killing_frame_clears_the_accumulator’s assertions (pre-flight F1). At 0.5 HP the pilot is alive and below 25%, so a heartbeat beat on that frame is correct spec behaviour. The test’s real claim is “no hit, one down”:

func test_killing_frame_clears_the_accumulator() -> void:
spy.record_plays = true
var p := _pilot(1.5)
d.update([p], 0, false, false)
p.hp = 0.5
d.update([p], 16, false, false)
p.hp = 0.0
d.update([p], 32, false, false)
assert_eq(spy.plays("hit").size(), 0, "the sub-floor loss never became a hit")
assert_eq(spy.plays("down").size(), 1, "down only")

Replace test_other_reactions_do_not_buzz with:

func test_reactions_without_a_pattern_stay_silent() -> void:
var p := _pilot()
d.update([p], 0, false, false)
d.note_events([{"kind": "reaction", "name": "FREEZE", "pos": Vector2.ZERO, "element": -1}, {"kind": "death"}])
d.update([p], 16, false, false)
assert_eq(spy.calls.size(), 0)

Append:

func test_nuke_reaches_every_pilot_once() -> void:
spy.record_plays = true
var a := _pilot()
var b := _pilot()
d.update([a, b], 0, false, false)
d.note_events([{"kind": "reaction", "name": "NUKE", "pos": Vector2.ZERO, "element": -1}])
d.update([a, b], 16, false, false)
d.update([a, b], 32, false, false)
assert_eq(spy.plays("nuke").map(func(c: Dictionary) -> int: return int(c["pilot"])), [0, 1])
func test_warp_ready_ticks_pads_when_the_cooldown_ends() -> void:
spy.record_plays = true
var p := _pilot()
p.dash_cd = 0.2
d.update([p], 0, false, false)
p.dash_cd = 0.0
d.update([p], 16, false, false)
d.update([p], 32, false, false)
assert_eq(spy.plays("warp_ready").size(), 1)
assert_false(bool(spy.plays("warp_ready")[0]["handheld_ok"]), "touch shows readiness on the button arc")
func test_evolving_a_weapon_plays_evolve_once() -> void:
spy.record_plays = true
var sim := Sim.new(4, SimContentFixture.db())
sim.dev_force_grant_weapon(sim.player, "wildfire")
d.update(sim.pilots, 0, false, false)
sim.player.arsenal.weapon_by_id["wildfire"].evolve()
d.update(sim.pilots, 16, false, false)
d.update(sim.pilots, 32, false, false)
assert_eq(spy.plays("evolve").size(), 1)
func test_heartbeat_beats_below_a_quarter_hull() -> void:
spy.record_plays = true
var p := _pilot(30.0)
d.update([p], 0, false, false)
d.update([p], 16, false, false)
assert_eq(spy.plays("heartbeat").size(), 0, "30% is above the threshold")
p.hp = 20.0
d.update([p], 100, false, false)
assert_eq(spy.plays("heartbeat").size(), 1, "first beat at once")
d.update([p], 600, false, false)
assert_eq(spy.plays("heartbeat").size(), 1)
d.update([p], 1200, false, false)
assert_eq(spy.plays("heartbeat").size(), 2, "every 1100 ms")
func test_heartbeat_is_suppressed_while_paused_down_or_under_a_wind_up() -> void:
spy.record_plays = true
var p := _pilot(20.0)
d.update([p], 0, false, false)
d.update([p], 16, true, false)
assert_eq(spy.plays("heartbeat").size(), 0, "paused")
d.boss_charge = 0.3
d.update([p], 2000, false, false)
assert_eq(spy.plays("heartbeat").size(), 0, "one dominant rhythm: the wind-up")
d.boss_charge = -1.0
p.hp = 0.0
d.update([p], 4000, false, false)
assert_eq(spy.plays("heartbeat").size(), 0, "down")
func test_heartbeat_threshold_and_period_are_live() -> void:
spy.record_plays = true
HapticsDirector.HEARTBEAT_FRAC = 0.5
HapticsDirector.HEARTBEAT_EVERY_S = 0.5
var p := _pilot(40.0)
d.update([p], 0, false, false)
d.update([p], 16, false, false)
d.update([p], 516, false, false)
assert_eq(spy.plays("heartbeat").size(), 2)
# HEARTBEAT_* are restored in after_each, not here (a failing test must not leak them).
  • Step 2: Run to verify failure (scripts/check-test-count.sh).

  • Step 3: Implement

In input/haptics_director.gd:

# Low-hull heartbeat: below HEARTBEAT_FRAC x max_hp a living pilot feels a "lub-dub" every
# HEARTBEAT_EVERY_S, except while paused or while a boss wind-up rumbles (one rhythm at a time).
# Live-tunable (net/tunables_registry.gd), since a heartbeat that nags is the likeliest complaint.
static var HEARTBEAT_FRAC: float = 0.25
static var HEARTBEAT_EVERY_S: float = 1.1

State: var _evolved: Array[int] = [], var _beat_ms: Array[int] = [], var _nukes := 0, all reset in reset(). The baseline branch appends _evolved_count(p) and 0.

The evolved count is computed ONCE per pilot per frame, in update (pre-flight F6). The store has to live in update: _pilot_events only runs while live, so storing only there would make an evolve during a muted frame burst on re-enable. In update’s loop, right after the baseline branch (if i >= _hp.size(): … continue), add var ev := _evolved_count(p). Call _pilot_events(i, p, now_ms, paused, ev) in the live branch. After _leech[i] = p.leech_healed add _evolved[i] = ev.

note_events: count "NUKE" reactions into _nukes beside "BOSS DOWN".

update: call _pilot_events(i, p, now_ms, paused, ev) (as above); after the boss-kill block:

if live and _nukes > 0:
for i in pilots.size():
output.play(i, "nuke")
_nukes = 0

_pilot_events(i: int, p: PlayerState, now_ms: int, paused: bool, evolved: int) additions (after the decoy line, before the leech block):

if _dash[i] > 0.0 and p.dash_cd <= 0.0:
output.play(i, "warp_ready", 1.0, false) # touch: the WARP button's arc already shows it
if evolved > _evolved[i]: # evolved = _evolved_count(p), computed once in update()
output.play(i, "evolve")
if p.hp > 0.0 and p.hp < HEARTBEAT_FRAC * p.max_hp and not paused and boss_charge < 0.0 \
and now_ms >= _beat_ms[i]:
output.play(i, "heartbeat")
_beat_ms[i] = now_ms + roundi(HEARTBEAT_EVERY_S * 1000.0)
# How many of this pilot's weapons are evolved (the sim emits no evolve event; a rise is one).
static func _evolved_count(p: PlayerState) -> int:
if p.arsenal == null:
return 0
var n := 0
for w: Variant in p.arsenal.weapon_by_id.values():
if w != null and bool(w.get("evolved")):
n += 1
return n
  • Step 4: Run scripts/check-test-count.sh → PASS. Gate, commit “feat(haptics): evolve, low-hull heartbeat, NUKE and warp-ready patterns”.

Task 7: Haptics tunables (console sliders) + docs + manifest

Section titled “Task 7: Haptics tunables (console sliders) + docs + manifest”

Files:

  • Modify: net/tunables_registry.gd (consts block + end of ENTRIES, before ] above NON_TUNABLE_STATIC_VARS ~line 790), tools/tunable_docs/generate.mjs (GROUP_RULES, ORDER)
  • Create: tools/tunable_docs/docs.d/haptics.json, tests/test_haptics_tunables.gd
  • Regenerate: control/src/tunable_docs.js. (NOT the manifest: the manifest is regenerated at the next BUILD bump. See Step 4.)

Interfaces:

  • Consumes: the HapticsOutput gains (Task 3), HapticsDirector.HEARTBEAT_* (Task 6).

  • Produces: 17 registry keys: HapticsOutput.INTENSITY, HapticsOutput.SHARPNESS, the 13 HapticsOutput.<ID>_INTENSITY, HapticsDirector.HEARTBEAT_FRAC, HapticsDirector.HEARTBEAT_EVERY_S; console group “Haptics”.

  • Step 1: Write the failing wiring test

Create tests/test_haptics_tunables.gd:

extends GutTest
# Haptics tunables (CoreHaptics plan Task 7) go through the REAL Tunables.apply path and reach the
# real play() / director decisions (the remote-config wiring rule: never hand-set state).
func after_each() -> void:
Tunables.reset()
HapticsOutput.native = null
func test_every_gain_is_registered_in_0_to_2() -> void:
var keys: Array = TunablesRegistry.ENTRIES.map(func(e: Dictionary) -> String: return String(e["key"]))
var want: Array[String] = ["HapticsOutput.INTENSITY", "HapticsOutput.SHARPNESS"]
for id: String in HapticPatterns.IDS:
want.append("HapticsOutput.%s_INTENSITY" % id.to_upper())
for k: String in want:
assert_has(keys, k)
for e: Dictionary in TunablesRegistry.ENTRIES:
if String(e["key"]) == k:
assert_eq([float(e["min"]), float(e["max"])], [0.0, 2.0], k)
func test_a_patched_gain_reaches_the_pad() -> void:
assert_eq(Tunables.apply({"HapticsOutput.DOWN_INTENSITY": 0.5}), [])
var spy := HapticsSpy.new()
spy.routes = [{"joypads": [4], "handheld": false}]
spy.play(0, "down")
assert_almost_eq(float(spy.calls[0]["weak"]), 0.5, 0.001)
func test_a_patched_sharpness_reaches_the_ahap() -> void:
Tunables.apply({"HapticsOutput.SHARPNESS": 0.5})
var fake := FakeDcHaptics.new()
HapticsOutput.native = fake
var spy := HapticsSpy.new()
spy.routes = [{"joypads": [], "handheld": true}]
spy.play(0, "decoy")
var params: Array = fake.of_kind("ahap")[0]["ahap"]["Pattern"][0]["Event"]["EventParameters"]
assert_almost_eq(float(params[1]["ParameterValue"]), 0.4, 0.001, "0.8 x 0.5")
func test_heartbeat_tunables_reach_the_director() -> void:
Tunables.apply({"HapticsDirector.HEARTBEAT_FRAC": 0.5})
assert_eq(HapticsDirector.HEARTBEAT_FRAC, 0.5)
var spy := HapticsSpy.new()
spy.record_plays = true
spy.routes = [{"joypads": [4], "handheld": false}]
var d := HapticsDirector.new()
d.output = spy
var p := PlayerState.new()
p.max_hp = 100.0
p.hp = 40.0
d.update([p], 0, false, false)
d.update([p], 16, false, false)
assert_eq(spy.plays("heartbeat").size(), 1, "40% beats once the threshold is 50%")
  • Step 2: Run to verify failure (scripts/check-test-count.sh).

  • Step 3: Register

net/tunables_registry.gd consts block:

const HAPTICS_OUTPUT := "res://input/haptics_output.gd"
const HAPTICS_DIRECTOR := "res://input/haptics_director.gd"

At the end of ENTRIES:

# CoreHaptics (2026-10-06): render-side feel gains, tuned live from the console, then baked into
# data/haptics/<id>.json. 1 = the pattern as authored; 0 silences it.
{"key": "HapticsOutput.INTENSITY", "script": HAPTICS_OUTPUT, "name": "INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.SHARPNESS", "script": HAPTICS_OUTPUT, "name": "SHARPNESS", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.HIT_INTENSITY", "script": HAPTICS_OUTPUT, "name": "HIT_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.HIT_HEAVY_INTENSITY", "script": HAPTICS_OUTPUT, "name": "HIT_HEAVY_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.DOWN_INTENSITY", "script": HAPTICS_OUTPUT, "name": "DOWN_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.LEVEL_UP_INTENSITY", "script": HAPTICS_OUTPUT, "name": "LEVEL_UP_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.EVOLVE_INTENSITY", "script": HAPTICS_OUTPUT, "name": "EVOLVE_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.LEECH_INTENSITY", "script": HAPTICS_OUTPUT, "name": "LEECH_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.HEARTBEAT_INTENSITY", "script": HAPTICS_OUTPUT, "name": "HEARTBEAT_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.BOSS_WINDUP_INTENSITY", "script": HAPTICS_OUTPUT, "name": "BOSS_WINDUP_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.BOSS_KILL_INTENSITY", "script": HAPTICS_OUTPUT, "name": "BOSS_KILL_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.NUKE_INTENSITY", "script": HAPTICS_OUTPUT, "name": "NUKE_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.WARP_INTENSITY", "script": HAPTICS_OUTPUT, "name": "WARP_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.DECOY_INTENSITY", "script": HAPTICS_OUTPUT, "name": "DECOY_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsOutput.WARP_READY_INTENSITY", "script": HAPTICS_OUTPUT, "name": "WARP_READY_INTENSITY", "min": 0.0, "max": 2.0},
{"key": "HapticsDirector.HEARTBEAT_FRAC", "script": HAPTICS_DIRECTOR, "name": "HEARTBEAT_FRAC", "min": 0.05, "max": 0.5},
{"key": "HapticsDirector.HEARTBEAT_EVERY_S", "script": HAPTICS_DIRECTOR, "name": "HEARTBEAT_EVERY_S", "min": 0.5, "max": 3.0},

tools/tunable_docs/generate.mjs: add [/^Haptics(Output|Director)\./, 'Haptics'], to GROUP_RULES (before the Visuals & FX rule) and 'Haptics' to ORDER after 'Visuals & FX'.

Create tools/tunable_docs/docs.d/haptics.json (labels ≤ 48 chars, descs 12-200 chars, no em dashes, no dates):

{
"HapticsOutput.INTENSITY": {"label": "Haptics: overall intensity", "desc": "Scales every haptic pattern, controller rumble and iPhone Taptic alike. 1 = as authored, 0 = silent.", "unit": "×", "src": "code"},
"HapticsOutput.SHARPNESS": {"label": "Haptics: overall sharpness", "desc": "Scales the sharpness of every iPhone Taptic pattern. Higher = crisper, more mechanical; lower = softer, rounder. Controllers ignore it.", "unit": "×", "src": "code"},
"HapticsOutput.HIT_INTENSITY": {"label": "Haptics: hit", "desc": "Intensity of the tap when a hit lands (scaled by the damage). 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.HIT_HEAVY_INTENSITY": {"label": "Haptics: heavy hit", "desc": "Intensity of a hit of 10% max HP or more, which adds a short rumble tail. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.DOWN_INTENSITY": {"label": "Haptics: ship lost", "desc": "Intensity of the long thump when a pilot's ship goes down. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.LEVEL_UP_INTENSITY": {"label": "Haptics: level up", "desc": "Intensity of the double tap on a level-up. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.EVOLVE_INTENSITY": {"label": "Haptics: weapon evolution", "desc": "Intensity of the rising triple tap and swell when a weapon evolves. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.LEECH_INTENSITY": {"label": "Haptics: Leech heal", "desc": "Intensity of the soft pulse each time Leech has healed 3% of max HP. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.HEARTBEAT_INTENSITY": {"label": "Haptics: low-hull heartbeat", "desc": "Intensity of the lub-dub heartbeat below the low-hull threshold. 1 = as authored, 0 turns it off.", "unit": "×", "src": "code"},
"HapticsOutput.BOSS_WINDUP_INTENSITY": {"label": "Haptics: boss wind-up", "desc": "Intensity of the rumble that builds while the Warden charges a barrage. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.BOSS_KILL_INTENSITY": {"label": "Haptics: boss kill", "desc": "Intensity of the thump every pilot feels when a boss dies. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.NUKE_INTENSITY": {"label": "Haptics: NUKE pickup", "desc": "Intensity of the deep thump when a NUKE clears the field. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.WARP_INTENSITY": {"label": "Haptics: warp", "desc": "Intensity of the quick swoosh when a pilot warps. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.DECOY_INTENSITY": {"label": "Haptics: Decoy launch", "desc": "Intensity of the tap when a Decoy launches. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsOutput.WARP_READY_INTENSITY": {"label": "Haptics: warp ready", "desc": "Intensity of the faint tick on controllers when the warp cooldown ends. 1 = as authored.", "unit": "×", "src": "code"},
"HapticsDirector.HEARTBEAT_FRAC": {"label": "Haptics: heartbeat threshold", "desc": "The heartbeat plays while hull is below this fraction of max HP. Lower = only when nearly dead.", "unit": "frac", "src": "code"},
"HapticsDirector.HEARTBEAT_EVERY_S": {"label": "Haptics: heartbeat interval", "desc": "Seconds between heartbeats while hull is low. Lower = a faster, more urgent pulse.", "unit": "s", "src": "code"}
}
  • Step 4: Regenerate
Terminal window
node tools/tunable_docs/generate.mjs
git status --short # expect control/src/tunable_docs.js; restore project.godot / audio/bus_layout.tres if listed

Do NOT run dump_manifest.gd here (pre-flight F4). remote-config/manifest/build-260.json describes BUILD 260, which is already installed and has no haptics statics. The manifest is regenerated at the next BUILD bump (bump-build.sh, at the Task 1 Step 13 / Task 9 Step 4 device install). The docs tests are keyed on the registry, not the manifest.

  • Step 5: Run scripts/check-test-count.sh, node --test control/test/*.test.mjs, node --test scripts/test/*.test.mjs → all PASS (tests/test_live_scope.gd too: these keys are read per play, so they are LIVE and need no LiveScope entry; if that test flags one, add it to LiveScope.TUNABLES as [LIVE, "", "read at each play"]).

  • Step 6: Gate and commit “feat(haptics): live haptics tunables (console Haptics group)”.


Task 8: Haptic Lab (Debug Settings row + console picker) + What’s new

Section titled “Task 8: Haptic Lab (Debug Settings row + console picker) + What’s new”

Files:

  • Modify: ui/debug_settings_panel.gd (_build_rows, new row builder, var _haptic_idx := 0), main.gd (apply_dev_command match, build_caps), control/src/panel/markup.js (after the Dimension card), control/src/panel/client.js (renderOptions call near line 230, GATED line 761), control/src/panel/tooltips.js, control/src/whats_new.js
  • Test: tests/test_main.gd (new test, AND append "haptic_play" to the exact dev_commands list asserted in test_build_caps_publishes_console_v2_data, pre-flight F3), tests/test_debug_console_parity.gd (no edit expected: it must pass with the new row)

Interfaces:

  • Consumes: HapticPatterns.IDS, HapticsOutput.last_path, haptics.output.play (Tasks 2-3).

  • Produces: dev command {"kind": "haptic_play", "id": <pattern id>}; caps haptic_patterns: Array[String]; "haptic_play" in caps dev_commands.

  • Step 1: Write the failing test

Append to tests/test_main.gd (follow that file’s existing boot helper for a live survival run; the parity tests use m._on_mode_chosen("survival") after one frame):

func test_haptic_play_command_plays_a_pattern_on_pilot_one() -> void:
var m: Node = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
var spy := HapticsSpy.new()
spy.record_plays = true
spy.routes = [{"joypads": [7], "handheld": false}]
m.haptics.output = spy
assert_true(m.apply_dev_command({"kind": "haptic_play", "id": "evolve"}))
assert_eq(spy.plays("evolve").size(), 1)
assert_true(m.apply_dev_command({"kind": "haptic_play", "id": "not_a_pattern"}), "handled, ignored")
assert_eq(spy.plays().size(), 1)
var caps: Dictionary = m.build_caps()
assert_eq(caps["haptic_patterns"], HapticPatterns.IDS)
assert_has(caps["dev_commands"], "haptic_play")

Note: _drive_haptics rebuilds output.routes every frame; the test calls apply_dev_command before any frame passes, so the spy’s routes stand.

Also in tests/test_main.gd, test_build_caps_publishes_console_v2_data pins the EXACT dev_commands array (pre-flight F3). Append "haptic_play" to its expected list, so it reads ["no_xp", "reset_all", "remove_crystals", "clear_crystals", "web_look", "set_tunables", "zero_gold", "haptic_play"].

  • Step 2: Run to verify failure (scripts/check-test-count.sh).

  • Step 3: Implement the game side

main.gd, inside apply_dev_command’s match:

"haptic_play":
# Haptic Lab (Debug Settings row + console picker): play one pattern on pilot 1's
# devices, exactly as gameplay would. Tune with the console's Haptics tunables.
var hid := String(cmd.get("id", ""))
if haptics != null and HapticPatterns.IDS.has(hid):
haptics.output.play(0, hid)

main.build_caps(): add "haptic_patterns": HapticPatterns.IDS, and append "haptic_play" to the "dev_commands" array.

ui/debug_settings_panel.gd: add var _haptic_idx := 0 beside the other row indices; in _build_rows() inside if _sim != null: after _rows.append(_timescale_row()), add if BuildConfig.dev_tools(): _rows.append(_haptic_lab_row()) (two lines); and:

# Haptic Lab (CoreHaptics plan Task 8): pick a pattern, Confirm plays it on pilot 1's devices. The
# suffix is the path the last play took (native = iPhone Taptic via DcHaptics, pad = rumble/plain
# vibrate, none = no haptic device). Intensity/sharpness sliders are the console's Haptics tunables.
func _haptic_lab_row() -> Dictionary:
var ids := HapticPatterns.IDS
return {
"label": "Haptic Lab",
"is_graphics": false,
"is_cycle": true,
"is_cycle_confirm": true,
"get_text": func() -> String:
var path := HapticsOutput.last_path
return "Haptic Lab: %s%s" % [ids[_haptic_idx], "" if path == "" else " (last: %s)" % path],
"cycle": func() -> void: _haptic_idx = (_haptic_idx + 1) % ids.size(),
"confirm": func() -> void: dev_command_requested.emit({"kind": "haptic_play", "id": ids[_haptic_idx]}),
"console": {"kind": "haptic_play", "caps": "haptic_patterns", "field": "id"},
}
  • Step 4: Implement the console side

control/src/panel/markup.js, after the Dimension card’s closing </div>:

<div class="card">
<h3>Haptic Lab</h3>
<p class="hint">Plays one haptic pattern on pilot 1's device. Tune it in Tunables, Haptics.</p>
<div class="row">
<select id="hap-sel"></select>
<button id="hap-play" data-tip="run-haptic" onclick="send({kind:'haptic_play',id:document.getElementById('hap-sel').value})">Play</button>
</div>
</div>

control/src/panel/client.js: after renderOptions('dim-sel', …) add renderOptions('hap-sel', (caps.haptic_patterns||[]).map(id=>[id, id]));, and extend GATED with ['hap-play','haptic_play'].

control/src/panel/tooltips.js, after 'run-dimension':

'run-haptic': {icon:'≋', title:'Play haptic', body:'Plays the chosen haptic pattern on pilot 1: the iPhone Taptic Engine if present, else controller rumble. Needs a run.', touches:'Haptics only'},

control/src/whats_new.js: there is NO build: null entry any more (the BUILD 260 bump stamped it; pre-flight F5). Create one at the TOP of WHATS_NEW. Its first line must be exactly build: null, date: null, and it needs a non-empty title; control/test/whats-new.test.mjs pins that shape. If another merge has added a build: null entry by then, append the item to its items instead:

{
build: null, date: null,
title: 'Premium haptics on iPhone (CoreHaptics) and a Haptic Lab',
items: [
{ text: 'Premium haptics on iPhone: the Taptic Engine now plays crafted patterns (crisp taps, soft swells, decaying thumps) instead of plain buzzes, through a new CoreHaptics extension. Every pattern was redesigned and controllers get matching rumble. New: a soft pulse as Leech heals you, a rising flourish when a weapon evolves, a heartbeat when your hull drops below a quarter, a deep thump on NUKE, and a faint tick on controllers when warp is ready. Dev builds: Debug Settings Haptic Lab plays any pattern and shows which path it took; this console has a Haptic Lab card and a Haptics group in Tunables for live intensity and sharpness.', where: 'iPhone, touch only: take hits at low hull, pick up a NUKE, evolve a weapon; Debug Settings: Haptic Lab' },
],
},
  • Step 5: Run everything

scripts/check-test-count.sh (incl. test_debug_console_parity.gd: the new row must pass its every-row checks unchanged), node --test control/test/*.test.mjs → PASS.

  • Step 6: Gate and commit “feat(haptics): Haptic Lab row + console picker; What’s new”. Note in the commit body: the console Worker needs a redeploy after merge (control/ changed).

Task 9: Docs, feel-check list, final review

Section titled “Task 9: Docs, feel-check list, final review”

Files:

  • Modify: docs/architecture/coop-and-pilots.md (§ Haptics, lines 65-105), platform/tvos/CLAUDE.md (addons/ bullet), memory bullet-heaven-haptics-and-coop4.md (controller does this, not the implementer)

  • Step 1: Update docs/architecture/coop-and-pilots.md § Haptics so it describes the present system:

    • Patterns are data: data/haptics/<id>.json (AHAP + pad), input/haptic_patterns.gd, validated by tests/test_haptic_patterns.gd; the 13 ids and what triggers each (spec §3 table plus hit_heavy and the warp-ready tick).
    • HapticsOutput.play() routing: Taptic iPhone handheld → DcHaptics.play_ahap, everything else → pad pulses scheduled by flush(); the director owns the WARP/DRONE phone buzz (TouchControls only taps for the Drone Bay hold); gains and how to tune (console Haptics tunables → bake into JSON → gain back to 1).
    • DcHaptics (platform/tvos/addons/dc_haptics): what it is, build.sh (gitignored bin, godot-cpp pin, stamp), the pack-only-shell guard, and that GDScript reaches it only via Engine.get_singleton.
    • PlayerState.leech_healed: render-read only, outside the checksum.
    • Replace the “On-device feel check” list with:
      • iPhone (native): every pattern from the Haptic Lab plays, the row says native, no console errors; haptics survive backgrounding and returning; Haptics Off silences native too.
      • iPhone in play: hit vs heavy hit feel different; heartbeat below 25% hull, stops on pause / during a Warden wind-up / at death; Leech pulses are soft and not constant; evolution flourish; NUKE thump; WARP/DRONE feel crisp (native), never double, and a press on cooldown is silent.
      • Apple TV + Xbox pad (pad fallback): the four new events plus the warp-ready tick are felt and distinct; the heartbeat is not annoying (else tune HapticsDirector.HEARTBEAT_*).
      • iPad (no Taptic Engine): the Lab row says pad; no DcHaptics errors.
  • Step 2: platform/tvos/CLAUDE.md: in the “addons/ is the exception” bullet, add that addons/dc_haptics/ holds TRACKED sources + .gdextension and a gitignored bin//out/ that build.sh fills (iOS + macOS editor; excluded from every non-iOS preset).

  • Step 3: Gate and commit “docs(haptics): CoreHaptics architecture + on-device feel check”.

  • Step 4 (controller): whole-branch review with bh-reviewer, merge via bh-merge-reviewed-branch, push, redeploy the console Worker, then a Dev install (full export, Task 1 Step 13) for Chris’s feel check. Update memory bullet-heaven-haptics-and-coop4.md.


  • Spec coverage: §1 native → Task 1; §2 patterns → Task 2; §3 table (all 12 + hit_heavy) → Tasks 2, 4, 5, 6; heartbeat suppression → Task 6; §4 routing + seams → Tasks 3-4 (the native seam is a fake singleton object, which also tests the call shape); §5 Leech → Task 5 (real kill path, checksum pin); §6 Lab + console + per-id tunables → Tasks 7-8 (deviation 4); §7 lifecycle → Task 1 (init_native, stop_all) + enabled short-circuit in Task 3; Testing → each task, device checks in Task 1 Step 13 and Task 9; Risks: spike = Task 1 Step 13 with the .gdip fallback named; heartbeat fatigue → tunables in Task 7.
  • Types/names: play(pilot, id, scale, handheld_ok, sharp), flush(now), id_gain(id), last_path, native, init_native(), HapticPatterns.{IDS, DIR, load_file, pad, scaled_ahap, problems}, FakeDcHaptics.{supports, calls, of_kind}, HapticsSpy.{record_plays, plays}, PlayerState.leech_healed, HapticsDirector.{HEARTBEAT_FRAC, HEARTBEAT_EVERY_S, LEECH_FRAC, LEECH_EVERY_MS, HIT_HEAVY_FRAC, WIND_SHARP_BASE, WIND_SHARP_SPAN} are used identically across tasks.
  • Known judgement calls for the implementer: Task 6 says to compute _evolved_count once per pilot per frame and store it in ONE place; Task 1 Step 9 names the fallback if godot-cpp symbols leak.

For a fresh session with no planning context.

  • Chris’s only instruction in the planning session: “continue with the spec”. All 8 deviations are now approved (see the ruling below).
  • Already done: spec + research committed and pushed (aacc0e34). This plan is committed on main. Nothing else was built, deployed or installed.
  • Not this plan’s files: the working tree on main carries another session’s uncommitted edits (tests/test_economy_career.gd, tools/economy_sim/*, remote-config/notice.json, plus docs/superpowers/plans/ neighbours). Never stage or revert them; implementers work in their own worktree off origin/main.
  • Needs Chris same-day: Task 1 Step 13 (iPhone device install via full iOS export), the console Worker redeploy after merge, any push of a merge. Tasks 1-12 steps and Tasks 2-8 are headless and need no device.
  • Environment facts found while planning: godot-cpp has no 4.7 tag (pin godot-4.5-stable, sha e83fd090…); scons at /opt/homebrew/bin/scons; Xcode 27; iOS min 15.0; install-dev.sh does --export-pack, which never adds a new GDExtension to the Xcode shell (hence the guard in Task 1 Step 11). In zsh, grep --include=*.gd glob-expands and fails; use git grep -- '*.gd'.
  • Ruling after the plan was first written (Chris, 2026-10-06): a side agent flagged that the buttons, not the haptics system, buzz the phone for warp/decoy. Chris: “change it to the new haptics system”. Deviation 7 and Tasks 3/4/7 now reflect that (no handheld_play shim; TouchControls loses its WARP/DRONE buzz). This ruling is approved. All 8 deviations approved (Chris, 2026-10-06: “approve the haptics deviations, start the plan”).
  • Next action: read the spec + this plan (deviations already approved), consult bh-sdd-concurrent, then dispatch Task 1 (Steps 1-12) to a bh-implementer (model opus) in .claude/worktrees/corehaptics. Tasks 2-8 can follow without waiting on the Step 13 device check.