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, consultbh-sdd-concurrentbefore Task 1 and dispatch implementers (bh-implementer,model: "opus") into their own worktree (.claude/worktrees/<name>, branch offorigin/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.
- Patterns live in
data/haptics/<id>.json, nothaptics/patterns/.platform/tvos/(the iOS export project) only sees symlinked root dirs;data/is one, a newhaptics/dir is not. Godot exports.jsonfiles already (data/bible.json). - Native API is
play_ahap(json: String), notplay_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_transientis dropped: the damage-scaled hit tap is thehitpattern at a scale. - 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.shandinstall-dev.shcallbuild.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 thegodot-4.5-stabletag (there is no 4.7 tag; GDExtension is forward-compatible and Sentry also declarescompatibility_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. - 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.gdrequires 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). - Evolution is detected by diffing each pilot’s evolved-weapon count, render-side. The sim emits no
evolvefx event (verified:UpgradeSystem.evolve_weaponand the crystal-rung path inSimonly callw.evolve()+log_op). hit_heavyis its own pattern id (13 ids, not 12): the heavy-hit tail is a different AHAP, not a branch insidehit.- 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_decoybuzz the phone themselves with a plainvibrate_handheld, and the director deliberately skips the phone for those two. The plan deletes the buttons’ own warp/decoy buzz and lets the director playwarp/decoyon 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. - Tunable keys follow the registry convention
HapticsOutput.INTENSITY,HapticsOutput.SHARPNESS,HapticsOutput.<ID>_INTENSITY, plusHapticsDirector.HEARTBEAT_FRAC/HEARTBEAT_EVERY_S(the spec’s risk section asks for those two to be tunable), instead ofhaptics.intensity.
Global Constraints
Section titled “Global Constraints”- Godot 4.7.1, typed GDScript, warnings as errors. Annotate
Dictionary.get()results asVariant(or an explicit type), never inferred:=. /simstays 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")andObject.call(...); never write the bare identifierDcHapticsin GDScript (parse error in the root project). HapticsOutput.enabled == falseshort-circuits before any native play call. (stop_all()still stops the native engine while disabled, since switching off must silence a playing pattern, andinit_native()prepares the engine at boot whatevermeta.hapticssays, 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 withscripts/gate.shgreen. - Node tests:
node --test scripts/test/*.test.mjsandnode --test control/test/*.test.mjs(file globs, not the directory form). - After any windowed or
platform/tvosGodot run:git status, and restoreproject.godot,audio/bus_layout.tres,platform/tvos/project.godotif 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/**/*.mdneeds Starlighttitle: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 map
Section titled “File map”| 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._notificationat 1848-1851 needs NO edit: it already callshaptics.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 callsnative.stop_all(). -
Produces (tests):
FakeDcHaptics(tests/fake_dc_haptics.gd) withsupports: bool,calls: Array,of_kind(kind) -> Array; eachplay_ahapcall 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 FakeDcHapticsextends 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 := truevar 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 outAppend 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 = nullconst 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 elsewheremain._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 godotplatform/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 JSONstatic 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 objectslib_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 pipefailHERE=$(cd "$(dirname "$0")" && pwd)CPP_TAG=godot-4.5-stableCPP_SHA=e83fd0904c13356ed1d4c3d09f8bb9132bdc6b77CACHE=${BH_GODOT_CPP_CACHE:-$HOME/Library/Caches/bh-godot-cpp}CPP=$CACHE/$CPP_SHAMIN_IOS=15.0STAMP=$( (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; fiif [ ! -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/.gdignoreJOBS=$(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=$Tdonerm -rf bin && mkdir -p bin/ios bin/macosfor 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/doneecho "$STAMP" > bin/.stampecho "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.shplatform/tvos/addons/dc_haptics/bin/platform/tvos/addons/dc_haptics/out/platform/tvos/addons/dc_haptics/src/*.osplatform/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:
B=platform/tvos/addons/dc_haptics/binfile $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.releaseotool -D $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.releaseotool -L $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release | grep -c CoreHapticsnm -gU $B/ios/libdc_haptics.ios.release.xcframework/ios-arm64/libdc_haptics.ios.release.framework/libdc_haptics.ios.release | grep dc_haptics_initotool -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 minosbash platform/tvos/addons/dc_haptics/build.shExpected: 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 SceneTreefunc _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):
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):
bash "$ROOT/platform/tvos/addons/dc_haptics/build.sh" # CoreHaptics: DcHaptics GDExtensionscripts/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):
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 fiUpdate the script’s header comment to say ios-shell checks both addons.
- Step 12: Gate and commit
Run: scripts/gate.sh → green. Then:
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.gdgit 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/strong0..1 are the two motors at scale 1;at_msis the offset from the play; a pulse WITHmin_mshas durationmax(min_ms, round(ms × scale)), a pulse without it has fixedms. -
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 HapticPatternsextends 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) -> voidfunc flush(now: int) -> void(setsnow_ms, sends every due pad pulse; drops them while disabled)var now_ms: intstatic var last_path: String("native","pad","none",""before any play)static func id_gain(id: String) -> float- static gains (all
float, default1.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) andHapticsSpy.plays(id := "") -> Arrayof{"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.0static var SHARPNESS: float = 1.0static var HIT_INTENSITY: float = 1.0static var HIT_HEAVY_INTENSITY: float = 1.0static var DOWN_INTENSITY: float = 1.0static var LEVEL_UP_INTENSITY: float = 1.0static var EVOLVE_INTENSITY: float = 1.0static var LEECH_INTENSITY: float = 1.0static var HEARTBEAT_INTENSITY: float = 1.0static var BOSS_WINDUP_INTENSITY: float = 1.0static var BOSS_KILL_INTENSITY: float = 1.0static var NUKE_INTENSITY: float = 1.0static var WARP_INTENSITY: float = 1.0static var DECOY_INTENSITY: float = 1.0static var WARP_READY_INTENSITY: float = 1.0and below var routes: Array = []:
var now_ms: int = 0 # the director's clock, set by flush(); pad pulses are scheduled against itvar _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(deletepulse()),ui/touch_controls.gd:176-200 - Test:
tests/test_haptics_director.gd,tests/test_haptics_output.gd,tests/test_haptics_main.gd(line 56 namesHapticsDirector.BOSS_KILL_MS, which this task deletes)
Interfaces:
-
Consumes:
HapticsOutput.play/flush(Task 3). -
Produces:
HapticsDirectorconstsHIT_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 indata/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: replaceHapticsDirector.DOWN_MSwith400. (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): replaceHapticsDirector.BOSS_KILL_MSwith300(the boss_kill padms). 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 continuouslyconst 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 cooldownconst WIND_BASE := 0.15 # boss wind-up scale = WIND_BASE + WIND_SPAN x chargeconst WIND_SPAN := 0.35const WIND_SHARP_BASE := 0.4 # ... and (iPhone) sharpness = WIND_SHARP_BASE + WIND_SHARP_SPAN x chargeconst WIND_SHARP_SPAN := 1.2const 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 thisconst DECOY_SPENT := 0.5 # decoy launch: drone_charge drops from >= 1.0 below thisDelete 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(afterdrone_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_healedundefined):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.0sim/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.logfortest_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. Gatescripts/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,pausedandevolved(the pilot’s evolved-weapon count, computed once per frame inupdate). -
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.25static var HEARTBEAT_EVERY_S: float = 1.1State: 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 ofENTRIES, before]aboveNON_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
HapticsOutputgains (Task 3),HapticsDirector.HEARTBEAT_*(Task 6). -
Produces: 17 registry keys:
HapticsOutput.INTENSITY,HapticsOutput.SHARPNESS, the 13HapticsOutput.<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
node tools/tunable_docs/generate.mjsgit status --short # expect control/src/tunable_docs.js; restore project.godot / audio/bus_layout.tres if listedDo 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.gdtoo: these keys are read per play, so they are LIVE and need noLiveScopeentry; if that test flags one, add it toLiveScope.TUNABLESas[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_commandmatch,build_caps),control/src/panel/markup.js(after the Dimension card),control/src/panel/client.js(renderOptionscall near line 230,GATEDline 761),control/src/panel/tooltips.js,control/src/whats_new.js - Test:
tests/test_main.gd(new test, AND append"haptic_play"to the exactdev_commandslist asserted intest_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>}; capshaptic_patterns: Array[String];"haptic_play"in capsdev_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), memorybullet-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 bytests/test_haptic_patterns.gd; the 13 ids and what triggers each (spec §3 table plushit_heavyand the warp-ready tick). HapticsOutput.play()routing: Taptic iPhone handheld →DcHaptics.play_ahap, everything else → pad pulses scheduled byflush(); 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 viaEngine.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; noDcHapticserrors.
- iPhone (native): every pattern from the Haptic Lab plays, the row says
- Patterns are data:
-
Step 2:
platform/tvos/CLAUDE.md: in the “addons/is the exception” bullet, add thataddons/dc_haptics/holds TRACKED sources +.gdextensionand a gitignoredbin//out/thatbuild.shfills (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 viabh-merge-reviewed-branch, push, redeploy the console Worker, then a Dev install (full export, Task 1 Step 13) for Chris’s feel check. Update memorybullet-heaven-haptics-and-coop4.md.
Self-review (done while writing)
Section titled “Self-review (done while writing)”- 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) +enabledshort-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.gdipfallback 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_countonce per pilot per frame and store it in ONE place; Task 1 Step 9 names the fallback if godot-cpp symbols leak.
Execution handoff
Section titled “Execution handoff”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
maincarries another session’s uncommitted edits (tests/test_economy_career.gd,tools/economy_sim/*,remote-config/notice.json, plusdocs/superpowers/plans/neighbours). Never stage or revert them; implementers work in their own worktree offorigin/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.shdoes--export-pack, which never adds a new GDExtension to the Xcode shell (hence the guard in Task 1 Step 11). In zsh,grep --include=*.gdglob-expands and fails; usegit 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_playshim; 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 abh-implementer(model opus) in.claude/worktrees/corehaptics. Tasks 2-8 can follow without waiting on the Step 13 device check.