Godot on the Couch: Siri Remote and Google TV Remote Input for Maze Point
The Remote Problem: Maze Point is a grid puzzle game, which makes it a natural fit for a television: four directions, a select button, and a pause button cover everything. In practice, our first TV builds were frustrating to play. On Apple TV, pressing Play/Pause on the Siri Remote did nothing at all. On Google TV, touch buttons sat on screen with no way to reach them, and in some builds the remote did not drive the game at all. Neither platform behaves like a phone with a keyboard attached, and Godot needed help on both.
Our approach was the same on both platforms. Translate whatever the operating system delivers into ordinary Godot input events as early as possible, so game code handles a TV remote the same way it handles a keyboard or gamepad.
1. Why Godot Never Saw the Siri Remote Buttons
On tvOS, the Siri Remote's touch surface and its click map reasonably well onto directional input. The physical buttons are a different story. UIKit delivers remote buttons such as Play/Pause and Menu as UIPress events (press types UIPressTypePlayPause and UIPressTypeMenu), routed through the responder chain via pressesBegan: and pressesEnded:. They are not keyboard events, and in our build the Play/Pause press never reached the engine's input pipeline. From GDScript's point of view, the button simply did not exist.
Pause is the action players reach for most on a TV, so that was not acceptable. Our first fix shipped with three interception points in the UIKit layer, forwarding both Play/Pause and Menu:
UIWindow sendEvent:, the earliest point where every event passes through the app, before any view sees it.pressesBegan:/pressesEnded:on the root view controller, the standard place to observe press phases.- A
UITapGestureRecognizerwithallowedPressTypeslimited to a specific press type, attached to the root view.
Overriding methods on classes Godot owns means swizzling, and swizzling engine classes is fragile across engine upgrades. We later narrowed the bridge to the gesture recognizer alone, forwarding only Play/Pause. It does not replace any engine method, it is scoped to exactly one press type, and with cancelsTouchesInView set to NO it does not interfere with the touch surface. It is what our tvOS post-export patch writes into the generated Xcode project today.
2. Turning a UIKit Press into a Godot Key Event
Once we had the press, we needed to inject it into Godot. Rather than invent a custom signal or plugin API, we synthesize a key event on the engine's display server, the same entry point that handles a hardware keyboard on Apple platforms. Simplified, the bridge looks like this:
// Objective-C++ in the exported tvOS project
+ (void)attachGesturesToWindow:(UIWindow *)window {
UIView *target = window.rootViewController.view ?: window;
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(handlePlayPause:)];
tap.allowedPressTypes = @[@(UIPressTypePlayPause)];
tap.cancelsTouchesInView = NO;
[target addGestureRecognizer:tap];
}
+ (void)handlePlayPause:(UITapGestureRecognizer *)gesture {
if (gesture.state == UIGestureRecognizerStateEnded) {
dispatch_godot_key(GODOT_KEY_MEDIAPLAY, true); // key down
dispatch_async(dispatch_get_main_queue(), ^{
dispatch_godot_key(GODOT_KEY_MEDIAPLAY, false); // key up next tick
});
}
}
A few details took some iteration. Godot's input system expects a press and a release, so we send the key-up on the next main-queue turn rather than in the same call. Otherwise is_action_pressed and is_action_just_pressed can disagree within one frame. We attach the recognizer when the app becomes active instead of at load time, because the root view controller does not exist that early, and we check for an existing recognizer first so returning from the background does not stack duplicates. We also set an environment flag at load so GDScript can tell it is running on tvOS even when the reported model name or feature tags are ambiguous.
The payoff is on the GDScript side. KEY_MEDIAPLAY is bound in the input map next to the pause and menu keys that keyboards, gamepads, and Android TV remotes already send:
{"action": ACTION_PAUSE, "keycode": KEY_MENU},
{"action": ACTION_PAUSE, "keycode": KEY_MEDIAPLAY},
{"action": ACTION_PAUSE, "keycode": KEY_PAUSE},
{"action": ACTION_ACCEPT, "keycode": KEY_MEDIAPLAY},
The pause handler runs from _input, so during play Play/Pause opens the pause menu, and pressing it again resumes. When a menu is open, the same key is treated as accept. No gameplay code knows a Siri Remote exists, and our headless UI tests can drive the whole flow with synthetic key events.
Respecting Menu-to-Exit on tvOS
Apple's TV guidelines expect the Menu button (Back on newer remotes) to step back through an app and, at the top level, return the player to the Home screen. Apps are not supposed to quit themselves. That is one reason the narrowed bridge leaves Menu alone: we do not intercept it, so tvOS keeps its default handling of that button. Inside the game, back semantics come from the shared bindings instead. KEY_MENU, KEY_BACK, Escape, and the gamepad Back button open pause during play and close or back out of pause, help, and result screens, marking the event handled. Quitting from the title screen on a back press is reserved for Android TV, where the platform expects it.
3. Google TV: Convincing Android It Is a Television App
On Google TV the remote events exist; the problem was that Android did not treat Maze Point as a TV app. Without the right manifest declarations, the launcher does not list the app in its TV row, and the system can fall back to touch-emulation behavior instead of sending D-pad key events straight to the game view. The fix lives in AndroidManifest.xml:
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
Marking every feature required="false" is what lets the same package install on phones and TVs. Declaring the touchscreen as optional is the part that matters most for TV, since Play only offers apps that do not require touch on those devices. We also added a launcher banner and enabled the export preset's Android TV flag, and tests assert the manifest contents so a template regeneration cannot silently remove them.
Normalizing Remote Key Codes
With events arriving, a second issue showed up. Several remote buttons (D-pad directions, center select, Back, Menu) came through with keycode equal to zero and the real value in physical_keycode or key_label. Our direction lookup read only keycode, so the player could not move. We now normalize every key event through one helper before any lookup:
static func get_normalized_keycode(event: InputEventKey) -> int:
if event.keycode != 0:
return event.keycode
if event.physical_keycode != 0:
return event.physical_keycode
return event.key_label
TV remotes also lack a Start button, so during active play the center select button opens pause, unless an animation is running, in which case it skips the animation. Android's system Back is handled through NOTIFICATION_WM_GO_BACK_REQUEST with set_quit_on_go_back(false), which lets us resume from pause or close dialogs instead of letting the engine exit immediately.
4. Hiding Touch-Only Controls and Fixing Focus Order
Our TV mode hides buttons that only make sense on a touchscreen, such as on-screen Retry, End Run, and Invert, because their actions are already bound to remote or gamepad input. TV mode was originally detected through tvOS-specific checks, so on Google TV it never turned on. Detection now also considers TV feature tags, known TV device model names, and Android display servers with no touchscreen. We also set hidden buttons to FOCUS_NONE. An invisible control that can still take focus is a trap: the highlight disappears and the next press seems to do nothing.
The final bug was subtle. Pressing down in a menu sometimes skipped a button. Our menu code moved focus itself but did not mark the event handled, so the event continued to Godot's built-in GUI focus system, which moved focus again from the newly focused button. Every directional event we handle now calls get_viewport().set_input_as_handled(). We also restricted Left and Right to controls that are actually horizontal, such as the language selector and volume sliders, so they no longer step through vertical button lists. The focus manager now re-syncs its index from gui_get_focus_owner(), and we reordered modal children so the focus order matches the on-screen layout. UI integration tests step down through a modal's buttons one press at a time, check the focus index after each step, confirm that focus wraps at both ends, and confirm that a horizontal press on a standard button leaves vertical focus where it was.
Lessons
- Convert platform-specific input into standard engine events at the boundary. Game logic stays portable and testable without hardware.
- On tvOS, a press-type-scoped gesture recognizer is a low-risk way to capture remote buttons without swizzling engine classes.
- Follow each platform's back-button convention: let tvOS handle Menu at the top level, and handle Back explicitly on Android.
- On Android TV, the manifest is part of the input stack. Leanback and optional touchscreen declarations change how events reach your app.
- Mark handled input as handled. A double-stepping focus bug usually means two systems are responding to the same event.