Skip to content

Dev voice feedback (R2) implementation plan

Dev voice feedback (R2) Implementation Plan

Section titled “Dev voice feedback (R2) 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.

Goal: In Dev builds, R2 on a controller opens the feedback panel with the text box focused and the system keyboard up, so a tester can dictate a note (iOS now; tvOS once the fork gains a keyboard).

Architecture: InputRouter edge-detects the right trigger (dev-gated) → main.gd routes it into the existing touch “feedback direct” path with a voice flag → PauseMenu opens FeedbackPanel in voice mode (kind OTHER, text box focused) → the note goes out through the unchanged GameplayTelemetry.send_feedback with context.voice = true. On tvOS, a fork change makes virtual_keyboard_show present a native UIAlertController text field and advertises OS.has_feature("tvos_text_input"), which un-hides the panel’s text box.

Tech Stack: Godot 4.7 typed GDScript + GUT; the ~/Claude/godot-tvos engine fork (Objective-C++).

Spec: docs/superpowers/specs/2026-10-06-dev-voice-feedback-design.md

  • R2 is Dev-only: gated on BuildConfig.dev_tools(). Store builds must be unaffected.
  • R2 = JOY_AXIS_TRIGGER_RIGHT (an axis, not a button). R1 (warp) / L1 (decoy) unchanged.
  • Reuse the existing feedback path; no new route, no new D1 column. The marker rides in context (stored as feedback.context_json by telemetry/src/worker.js feedbackRow, which keeps the whole context object — verified 2026-10-06, so no Worker change).
  • Do not change the PauseMenu.feedback_submitted(kind, note) or FeedbackPanel.submitted(kind, note) signal signatures (tests/test_diagnostics_toggle.gd:245 emits the 2-arg form).
  • /sim untouched; determinism baselines must not move.
  • Work in a worktree (.claude/worktrees/voice-feedback, branch off origin/main), per CLAUDE.md.
  • Test runs: scripts/check-test-count.sh (never a raw GUT run into context); a single file: godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit 2>&1 | tail -20. Run godot --headless --path . --import first in a fresh worktree.
  • Any new docs/**/*.md needs Starlight title: frontmatter.

Task 1: InputRouter R2 trigger edge (dev-gated)

Section titled “Task 1: InputRouter R2 trigger edge (dev-gated)”

Files:

  • Modify: input/input_router.gd (add after _all_joypad_button_down, ~line 169)
  • Test: tests/test_input_router.gd (append)

Interfaces:

  • Produces: static func trigger_edge(value: float, was_held: bool) -> Dictionary returning {"fire": bool, "held": bool}; var feedback_trigger_enabled: bool (defaults to BuildConfig.dev_tools()); func poll_feedback_trigger() -> bool (true once per R2 press on any playable pad).

  • Step 1: Write the failing tests (append to tests/test_input_router.gd)

# Dev voice feedback (2026-10-06): R2 is an analog trigger; fire once per squeeze, with hysteresis.
func test_trigger_edge_fires_once_per_press() -> void:
var e := InputRouter.trigger_edge(0.9, false)
assert_true(bool(e["fire"]), "squeeze past 0.5 fires")
assert_true(bool(e["held"]))
e = InputRouter.trigger_edge(0.9, true)
assert_false(bool(e["fire"]), "still held: no repeat")
e = InputRouter.trigger_edge(0.4, true)
assert_false(bool(e["fire"]))
assert_true(bool(e["held"]), "between 0.3 and 0.5 stays held (hysteresis)")
e = InputRouter.trigger_edge(0.1, true)
assert_false(bool(e["held"]), "below 0.3 re-arms")
e = InputRouter.trigger_edge(0.6, false)
assert_true(bool(e["fire"]), "re-armed: fires again")
func test_trigger_edge_ignores_a_light_touch() -> void:
var e := InputRouter.trigger_edge(0.4, false)
assert_false(bool(e["fire"]))
assert_false(bool(e["held"]))
func test_feedback_trigger_off_when_not_dev() -> void:
var r := InputRouter.new()
r.feedback_trigger_enabled = false
assert_false(r.poll_feedback_trigger(), "store build: R2 never opens feedback")
r.free()
  • Step 2: Run, verify FAIL — godot --headless --path . -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_input_router.gd -gexit 2>&1 | tail -20. Expected: parse/“nonexistent function trigger_edge” failures.

  • Step 3: Implement (in input/input_router.gd, after _all_joypad_button_down)

# Dev voice feedback (2026-10-06, spec docs/superpowers/specs/2026-10-06-dev-voice-feedback-design.md):
# R2 (the right TRIGGER, an axis) opens the feedback panel ready for dictation. Dev builds only.
# Pure edge with hysteresis: fire on crossing 0.5, re-arm only below 0.3, so a trigger resting
# half-pressed can't machine-gun the panel.
const TRIGGER_PRESS := 0.5
const TRIGGER_RELEASE := 0.3
static func trigger_edge(value: float, was_held: bool) -> Dictionary:
if was_held:
return {"fire": false, "held": value >= TRIGGER_RELEASE}
var pressed := value >= TRIGGER_PRESS
return {"fire": pressed, "held": pressed}
var feedback_trigger_enabled: bool = BuildConfig.dev_tools()
var _r2_held := false
# Call every physics frame (even while frozen) so the edge state stays current.
func poll_feedback_trigger() -> bool:
if not feedback_trigger_enabled:
_r2_held = false
return false
var v := 0.0
for dev in playable_joypads():
v = maxf(v, Input.get_joy_axis(dev, JOY_AXIS_TRIGGER_RIGHT))
var e := trigger_edge(v, _r2_held)
_r2_held = bool(e["held"])
return bool(e["fire"])
  • Step 4: Run, verify PASS (same command).

  • Step 5: Commit

Terminal window
git add input/input_router.gd tests/test_input_router.gd
git commit -m "feat(input): dev-only R2 trigger edge for voice feedback"

Task 2: FeedbackPanel voice mode + tvOS text-entry gate

Section titled “Task 2: FeedbackPanel voice mode + tvOS text-entry gate”

Files:

  • Modify: ui/feedback_panel.gd (text_entry_available ~line 40; _ready tail ~line 152; header comment lines 7-8)
  • Test: tests/test_feedback_panel.gd (append)

Interfaces:

  • Produces: var voice := false on FeedbackPanel (set BEFORE add_child); static func _text_entry_for(os_name: String, tvos_text_input: bool) -> bool; text_entry_available() keeps its name/signature.

  • Step 1: Write the failing tests (append)

# Dev voice feedback (2026-10-06): tvOS gets the text box only when the engine fork has a keyboard.
func test_text_entry_gate_follows_tvos_keyboard_feature() -> void:
assert_true(FeedbackPanel._text_entry_for("iOS", false))
assert_true(FeedbackPanel._text_entry_for("macOS", false))
assert_false(FeedbackPanel._text_entry_for("tvOS", false), "old template: quick notes only")
assert_true(FeedbackPanel._text_entry_for("tvOS", true), "fork keyboard present")
func test_voice_opens_on_other_with_the_box_focused() -> void:
var p := FeedbackPanel.new()
p.voice = true
add_child_autofree(p)
if p._line == null:
pass_test("no text entry on this platform")
return
assert_eq(p.current_kind(), "other")
assert_eq(p._rows[p._sel], p._line, "nav selection is the text box")
assert_true(p._line.has_focus(), "focused, so the system keyboard comes up")
func test_voice_off_keeps_the_old_opening() -> void:
var p := FeedbackPanel.new()
add_child_autofree(p)
assert_eq(p.current_kind(), "bug")
assert_eq(p._sel, 0)
  • Step 2: Run, verify FAIL (-gtest=res://tests/test_feedback_panel.gd).

  • Step 3: Implement

Replace text_entry_available:

static func text_entry_available() -> bool:
return _text_entry_for(OS.get_name(), OS.has_feature("tvos_text_input"))
# Pure core. tvOS had no keyboard at all until the godot-tvos fork's UIAlertController keyboard
# (2026-10-06), which advertises the "tvos_text_input" feature; an older template keeps quick notes.
static func _text_entry_for(os_name: String, tvos_text_input: bool) -> bool:
return os_name != "tvOS" or tvos_text_input

Add the var next to var audio: AudioManager:

var voice := false # Dev R2 (2026-10-06): open on OTHER with the text box focused, ready to dictate

Replace the last three lines of _ready() (_set_kind(0) / _sel = 0 / _focus()) with:

if voice and _line != null:
_set_kind(KINDS.find("other"))
_sel = _rows.find(_line)
_line.grab_focus() # _focus() deliberately skips the box; here we WANT the keyboard up
_line.set_caret_line(_line.get_line_count() - 1)
_line.set_caret_column(_line.get_line(_line.get_line_count() - 1).length())
else:
_set_kind(0)
_sel = 0
_focus()

Update the header comment line “The typed line is hidden on tvOS, where a controller has no sensible text entry” to: “The typed line is hidden on tvOS unless the engine fork provides a keyboard (feature tvos_text_input, 2026-10-06)”.

  • Step 4: Run, verify PASS (same file).

  • Step 5: Commit

Terminal window
git add ui/feedback_panel.gd tests/test_feedback_panel.gd
git commit -m "feat(feedback): voice mode + tvOS keyboard feature gate"

Task 3: Wire R2 → pause → voice panel → send with context.voice

Section titled “Task 3: Wire R2 → pause → voice panel → send with context.voice”

Files:

  • Modify: net/gameplay_telemetry.gd:72-91 (feedback_body, send_feedback)
  • Modify: ui/pause_menu.gd:266-271 (open_feedback_then_resume), :326-339 (_open_feedback), plus a new var feedback_voice
  • Modify: main.gd:585-586 (send closure), main.gd _physics_process (~1527-1538), main.gd:2678-2683 (_open_feedback_direct)
  • Modify: control/src/whats_new.js (unbuilt entry)
  • Test: tests/test_telemetry_reporters_queue.gd, tests/test_feedback_panel.gd (append)

Interfaces:

  • Consumes: InputRouter.poll_feedback_trigger() -> bool (Task 1); FeedbackPanel.voice (Task 2).

  • Produces: GameplayTelemetry.feedback_body(kind, note, session, device, sim, input_style, voice := false); send_feedback(kind, note, sim, input_style, voice := false); PauseMenu.open_feedback_then_resume(voice := false); PauseMenu.feedback_voice: bool; main._open_feedback_direct(voice := false).

  • Step 1: Write the failing tests

Append to tests/test_feedback_panel.gd:

func test_feedback_body_marks_voice_in_context() -> void:
var b := GameplayTelemetry.feedback_body("other", "boss two is brutal", "", "", null, "pad", true)
assert_true(bool(b["context"]["voice"]))
var plain := GameplayTelemetry.feedback_body("bug", "it crashed", "", "", null, "pad")
assert_false(plain["context"].has("voice"), "typed notes carry no marker")
func test_pause_menu_voice_open_sets_panel_voice() -> void:
var m := PauseMenu.new()
add_child_autofree(m)
m.share_diagnostics = true
m.open_feedback_then_resume(true)
assert_true(m.feedback_voice)
assert_not_null(m._feedback)
assert_true(m._feedback.voice)

(If PauseMenu.new() needs setup the existing tests/test_diagnostics_toggle.gd does — read it first and mirror its construction exactly.)

  • Step 2: Run, verify FAIL.

  • Step 3: Implement

net/gameplay_telemetry.gd:

static func feedback_body(kind: String, note: String, session: String, device: String, sim: Sim, input_style: String, voice := false) -> Dictionary:
var ctx := {"input": input_style}
if voice:
ctx["voice"] = true # Dev R2 dictation (2026-10-06), so bh-feedback-triage can tell them apart
# ... rest unchanged
func send_feedback(kind: String, note: String, sim: Sim, input_style: String, voice := false) -> void:
if not _enabled or _queue == null:
return
var body := feedback_body(kind, note, "", "", sim, input_style, voice)
# ... rest unchanged

ui/pause_menu.gd — add near var _feedback:

var feedback_voice := false # the open panel came from Dev R2 (main reads it when sending)
func open_feedback_then_resume(voice := false) -> void:
if not share_diagnostics:
_sel = _buttons.find(_diag_btn)
_focus_sel()
return
_open_feedback(true, voice)
func _open_feedback(resume_after := false, voice := false) -> void:
if _feedback != null and is_instance_valid(_feedback):
return
if not share_diagnostics:
return # confirm / hover emit `pressed` by hand, past `disabled`: nothing to send while off
feedback_voice = voice
_feedback = FeedbackPanel.new()
_feedback.audio = audio
_feedback.voice = voice
# ... rest unchanged

main.gd send closure (line 585):

pause_menu.feedback_submitted.connect(func(kind: String, note: String) -> void:
gameplay_telemetry.send_feedback(kind, note, sim, ButtonPrompt.current_style(), pause_menu.feedback_voice))

main.gd _physics_process: poll every frame (edge state stays current while frozen), act only when not frozen. Insert right after the var _frozen := ... statement:

var _r2_feedback := input_router.poll_feedback_trigger() # Dev-only R2 → dictated feedback

and right after if _frozen: return:

if _r2_feedback:
_open_feedback_direct(true)
return

main.gd _open_feedback_direct:

func _open_feedback_direct(voice := false) -> void:
if _paused_for_menu:
return
_toggle_pause()
if _paused_for_menu and pause_menu != null and is_instance_valid(pause_menu):
pause_menu.open_feedback_then_resume(voice)

control/src/whats_new.js: add (or append to) the top build: null, date: null, entry, keeping that first line exact:

{
build: null, date: null,
title: 'Dev builds: R2 opens feedback ready to dictate',
items: [
{ text: 'Dev builds only: squeeze R2 (right trigger) on a controller mid-run to pause and open Send feedback with the text box focused. On iPhone tap the keyboard mic to dictate; on Apple TV (once the keyboard-enabled engine ships) hold the Siri Remote mic. The note is tagged as voice in the feedback inbox.', where: 'In a run: R2' },
],
},

(Match the item shape of existing entries — check whether where is used; control/test/whats-new.test.mjs pins it.)

  • Step 4: Run tests — the two files, then node --test control/test/whats-new.test.mjs, then scripts/check-test-count.sh. All PASS.

  • Step 5: Manual check on Mac (editor = dev): godot --path . --rendering-method mobile, start Survival, squeeze R2 on a connected pad → pause + feedback on OTHER, caret in the box; type, Send, game resumes. Then git checkout -- project.godot audio/bus_layout.tres.

  • Step 6: Gate + commit

Terminal window
scripts/gate.sh
git add net/gameplay_telemetry.gd ui/pause_menu.gd main.gd control/src/whats_new.js tests/test_feedback_panel.gd
git commit -m "feat(feedback): Dev R2 opens voice feedback; context.voice marker"

Then merge to main (reviewer first, bh-merge-reviewed-branch), push, and do an iPhone Dev install (bh-deploy / scripts/dev/install-dev.sh, BUILD bump per CLAUDE.md, checking live branches for higher BUILDs). On-device check: R2 → keyboard → mic → dictate → Send → row in D1 feedback with "voice":true in context_json (skill bh-telemetry).


Task 4: Engine fork — tvOS virtual keyboard (~/Claude/godot-tvos/godot, branch tvos-render-1080p)

Section titled “Task 4: Engine fork — tvOS virtual keyboard (~/Claude/godot-tvos/godot, branch tvos-render-1080p)”

Not in the game repo; no GUT coverage. Local-only branch, never pushed.

Files:

  • Modify: platform/tvos/display_server_tvos.h (public overrides)
  • Modify: platform/tvos/display_server_tvos.mm (replace the // TODO: tvOS virtual keyboard support. block, ~line 99)
  • Modify: drivers/apple_embedded/os_apple_embedded.mm:750 (_check_internal_feature_support)

Interfaces:

  • Produces: OS.has_feature("tvos_text_input") == true on tvOS; virtual_keyboard_show presents a native text prompt; Done injects text through DisplayServerAppleEmbedded::key.

  • Step 1: Header — in DisplayServerTVOS public: add:

virtual void virtual_keyboard_show(const String &p_existing_text, const Rect2 &p_screen_rect, DisplayServerEnums::VirtualKeyboardType p_type, int p_max_length, int p_cursor_start, int p_cursor_end) override;
virtual void virtual_keyboard_hide() override;
virtual bool is_keyboard_active() const override;

(Check is_keyboard_active is virtual in display_server_apple_embedded.h; if not, make it virtual there.)

  • Step 2: Implementation — replace the TODO block in display_server_tvos.mm:
// Dark Cosmos (2026-10-06): tvOS virtual keyboard. UIKeyInput on a custom view never raises the tvOS
// keyboard (rdar://27389949), so present a UIAlertController with a UITextField instead: focusing
// it opens the full-screen tvOS keyboard, which has dictation (Siri Remote mic). On Done the
// Godot control's text is replaced via key events (the iOS keyboard view's path): one BACKSPACE
// per UTF-32 char of the existing text (the caller puts the caret at the end), then the new text.
static UIAlertController *gdt_tvos_kb_alert = nil;
static void gdt_tvos_send_text(const String &p_old, NSString *p_new) {
DisplayServerAppleEmbedded *ds = DisplayServerAppleEmbedded::get_singleton();
if (!ds) {
return;
}
for (int i = 0; i < p_old.length(); i++) {
ds->key(Key::BACKSPACE, 0, Key::BACKSPACE, Key::NONE, 0, true, KeyLocation::UNSPECIFIED);
ds->key(Key::BACKSPACE, 0, Key::BACKSPACE, Key::NONE, 0, false, KeyLocation::UNSPECIFIED);
}
String s = String::utf8([p_new UTF8String]);
for (int i = 0; i < s.length(); i++) {
char32_t c = s[i];
Key k = (c == '\n') ? Key::ENTER : Key::NONE;
ds->key(k, c == '\n' ? 0 : c, k, Key::NONE, 0, true, KeyLocation::UNSPECIFIED);
ds->key(k, c == '\n' ? 0 : c, k, Key::NONE, 0, false, KeyLocation::UNSPECIFIED);
}
}
void DisplayServerTVOS::virtual_keyboard_show(const String &p_existing_text, const Rect2 &p_screen_rect, DisplayServerEnums::VirtualKeyboardType p_type, int p_max_length, int p_cursor_start, int p_cursor_end) {
if (gdt_tvos_kb_alert) {
return;
}
String old = p_existing_text;
NSString *existing = [NSString stringWithUTF8String:p_existing_text.utf8().get_data()];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *field) {
field.text = existing;
}];
[alert addAction:[UIAlertAction actionWithTitle:@"Done" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
NSString *t = alert.textFields.firstObject.text ?: @"";
gdt_tvos_kb_alert = nil;
if (![t isEqualToString:existing]) {
gdt_tvos_send_text(old, t);
}
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:^(UIAlertAction *a) {
gdt_tvos_kb_alert = nil;
}]];
gdt_tvos_kb_alert = alert;
[GDTAppDelegateService.viewController presentViewController:alert animated:YES completion:nil];
}
void DisplayServerTVOS::virtual_keyboard_hide() {
if (gdt_tvos_kb_alert) {
[gdt_tvos_kb_alert dismissViewControllerAnimated:YES completion:nil];
gdt_tvos_kb_alert = nil;
}
}
bool DisplayServerTVOS::is_keyboard_active() const {
return gdt_tvos_kb_alert != nil;
}

(Verify GDTAppDelegateService.viewController is the accessor used elsewhere in the fork — it is in display_server_apple_embedded.mm:720. Verify DisplayServerAppleEmbedded::key is public; it is called from godot_keyboard_input_view.mm.)

  • Step 3: Feature string — in OS_AppleEmbedded::_check_internal_feature_support, before return false;:
#ifdef TVOS_ENABLED
if (p_feature == "tvos_text_input") {
return true; // Dark Cosmos: DisplayServerTVOS presents a UIAlertController keyboard (2026-10-06)
}
#endif
  • Step 4: Build + repackage the tvOS export templates exactly per bh-appstore-release §0.55 (read it; do not improvise the scons flags). Verify: strings <built libgodot.tvos binary> | grep tvos_text_input prints a hit.

  • Step 5: Commit on the fork branch (local only):

Terminal window
cd ~/Claude/godot-tvos/godot
git add platform/tvos/display_server_tvos.h platform/tvos/display_server_tvos.mm drivers/apple_embedded/os_apple_embedded.mm
git commit -m "tvos: UIAlertController virtual keyboard + tvos_text_input feature"
  • Step 6: Apple TV Dev install (bh-deploy / bh-worktree-device-install, BUILD bump). On device: R2 → feedback panel shows the text box on OTHER → alert opens → hold Siri Remote mic, dictate → Done → text appears in the box → Send → D1 row with "voice":true. Watch for the pad’s A/B also acting in Godot under the alert (spec risk); if it does, gate FeedbackPanel._input on DisplayServer.virtual_keyboard_get_height()/an is_keyboard_active check and re-test.

  • Step 7: Docs — note the fork keyboard in platform/tvos/CLAUDE.md (one line under platform rules) and the R2 control in docs/architecture/dev-tools.md (Remote Playtest / dev controls section). Commit + push the game repo.

Recorded 2026-10-06 at the end of the planning session.

Chris’s decisions (brainstorm):

  • Build for BOTH iPhone/iPad and Apple TV; reuse the existing in-game feedback path (earlier session, “yes to both”).
  • tvOS dictation via the Siri Remote mic is acceptable (answer “A”): Toby presses R2 on the controller, then picks up the remote. Controller-only voice on tvOS was ruled impossible.
  • tvOS approach = option 1, fork change (implement virtual_keyboard_show in godot-tvos). Rejected: option 2 (file-handshake bridge in platform/tvos/patches/dummy.mm, hacky polling) and option 3 (tvOS GDExtension, unknown whether the fork/godot-cpp load GDExtensions on tvOS).
  • All three spec sections approved as written (“yes” x3), spec approved (“go”).

Correction made during design (not obvious from code): FEATURE_VIRTUAL_KEYBOARD already returns true on tvOS via shared drivers/apple_embedded/display_server_apple_embedded.mm:392, so it CANNOT gate the text box — hence the fork-specific tvos_text_input feature string.

Verified during planning: the telemetry Worker’s feedbackRow stores the whole context object as context_json, so context.voice needs no Worker/schema change.

Already done: spec committed + pushed (968def41). Nothing else touched. The main checkout has UNRELATED uncommitted economy-sim edits (tests/test_economy_career.gd, tools/economy_sim/*) and an untracked remote-config/notice.json from 2026-10-04 — not this task’s; leave them alone, work in a worktree.

Authorization: implementation, gate, worktree commits are fine. Still needs Chris’s same-day OK: merge/push to main (auto-mode has blocked Claude’s git push origin HEAD:main before — hand Chris the ! push line if blocked), each device Dev install (BUILD bumps), the fork template rebuild/repackage (Task 4 Step 4, it feeds store tvOS builds too), any Worker redeploy (console Worker after control/ change).

Fork state: ~/Claude/godot-tvos/godot on branch tvos-render-1080p, HEAD 007f6f1, with untracked misc/dist/apple_embedded_xcode/libgodot.tvos.{debug,release}.xcframework/ (existing build output, leave it).

Next action: Task 1 in a worktree .claude/worktrees/voice-feedback off origin/main. Read bh-sdd-concurrent before dispatching Task 1 (concurrent sessions are common on this repo). Pass model: "opus" to code subagents (they default to Haiku). Tasks 1-3 are game-repo; Task 4 is the fork and needs Chris’s devices.