feat: engine input events (key/mouse-button/gamepad) for flows#606
Conversation
Add six input events to the engine Events catalog so flows can react to
user input via OnEvent (Option B, engine-only change):
engine.key_pressed / key_released { key }
engine.mouse_button_pressed / released { button, x, y }
engine.gamepad_connected / disconnected { id }
Game.tick scans the unified InputInterface (Self.Input) at the tail of
the active-frame body — never a backend's native API — so emitted events
buffer alongside this frame's lifecycle events and drain on the same
dispatchEvents. Keyboard/mouse/gamepad scans are each comptime-gated on
@Hasfield(GameEvents, tag): an event-less game runs no scan and pays
nothing. Gamepad connect/disconnect edges are synthesized by diffing
isGamepadAvailable across frames via prev_gamepad_connected[4]
(max_tracked_gamepads = raylib's MAX_GAMEPADS cap). The key_null=0
sentinel is skipped.
The assembler auto-discovers engine.Events, so these compose into the
flow-visible event union with no assembler change.
PR SummaryMedium Risk Overview Each frame, Scanning is comptime-gated per category and per variant ( Reviewed by Cursor Bugbot for commit fd7b9a5. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request introduces engine-hosted input events (keyboard, mouse, and gamepad connect/disconnect) scanned in Game.tick through a unified InputInterface and dual-emitted on the buffered event path. Feedback suggests replacing the inline for loops in scanInputEvents with standard runtime for loops to reduce compile-time overhead, prevent binary size bloat, and avoid having to increase the evaluation branch quota.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| fn scanInputEvents(self: *Self) void { | ||
| // The keyboard scan unrolls `emitEngineEvent` (itself an | ||
| // `inline for` over the payload fields) across the full | ||
| // `KeyboardKey` enum (~100 keys), which blows past the | ||
| // default 1000-branch comptime budget. Raise it. | ||
| @setEvalBranchQuota(20_000); | ||
|
|
||
| // ── Keyboard ── | ||
| if (comptime keyboard_events_wanted) { | ||
| inline for (comptime std.enums.values(input_types.KeyboardKey)) |k| { | ||
| // Skip the `key_null = 0` sentinel — not a real key. | ||
| if (comptime k != .key_null) { | ||
| const code: u32 = @intCast(@intFromEnum(k)); | ||
| if (comptime engineEventWanted("engine__key_pressed")) { | ||
| if (Self.Input.isKeyPressed(code)) | ||
| self.emitEngineEvent("engine__key_pressed", .{ .key = code }); | ||
| } | ||
| if (comptime engineEventWanted("engine__key_released")) { | ||
| if (Self.Input.isKeyReleased(code)) | ||
| self.emitEngineEvent("engine__key_released", .{ .key = code }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // ── Mouse buttons ── | ||
| if (comptime mouse_events_wanted) { | ||
| inline for (comptime std.enums.values(input_types.MouseButton)) |b| { | ||
| const code: u32 = @intCast(@intFromEnum(b)); | ||
| if (comptime engineEventWanted("engine__mouse_button_pressed")) { | ||
| if (Self.Input.isMouseButtonPressed(code)) | ||
| self.emitEngineEvent("engine__mouse_button_pressed", .{ | ||
| .button = code, | ||
| .x = Self.Input.getMouseX(), | ||
| .y = Self.Input.getMouseY(), | ||
| }); | ||
| } | ||
| if (comptime engineEventWanted("engine__mouse_button_released")) { | ||
| if (Self.Input.isMouseButtonReleased(code)) | ||
| self.emitEngineEvent("engine__mouse_button_released", .{ | ||
| .button = code, | ||
| .x = Self.Input.getMouseX(), | ||
| .y = Self.Input.getMouseY(), | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // ── Gamepad connect / disconnect (engine-side edge diff) ── | ||
| if (comptime gamepad_events_wanted) { | ||
| comptime var gi: u32 = 0; | ||
| inline while (gi < max_tracked_gamepads) : (gi += 1) { | ||
| const now = Self.Input.isGamepadAvailable(gi); | ||
| const was = self.prev_gamepad_connected[gi]; | ||
| if (now and !was) { | ||
| if (comptime engineEventWanted("engine__gamepad_connected")) | ||
| self.emitEngineEvent("engine__gamepad_connected", .{ .id = gi }); | ||
| } else if (!now and was) { | ||
| if (comptime engineEventWanted("engine__gamepad_disconnected")) | ||
| self.emitEngineEvent("engine__gamepad_disconnected", .{ .id = gi }); | ||
| } | ||
| self.prev_gamepad_connected[gi] = now; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Using inline for to unroll the loop over all ~114 keys in KeyboardKey causes significant compile-time overhead and binary size bloat because emitEngineEvent is inlined for every single key. This is why @setEvalBranchQuota(20_000) was required.
Since the loop can be evaluated at runtime, we can use a standard runtime for loop instead. This completely avoids loop unrolling, reduces binary size, improves compile times, and eliminates the need for @setEvalBranchQuota.
fn scanInputEvents(self: *Self) void {
// ── Keyboard ──
if (comptime keyboard_events_wanted) {
const pressed_wanted = comptime engineEventWanted("engine__key_pressed");
const released_wanted = comptime engineEventWanted("engine__key_released");
for (std.enums.values(input_types.KeyboardKey)) |k| {
if (k == .key_null) continue;
const code: u32 = @intCast(@intFromEnum(k));
if (pressed_wanted) {
if (Self.Input.isKeyPressed(code))
self.emitEngineEvent("engine__key_pressed", .{ .key = code });
}
if (released_wanted) {
if (Self.Input.isKeyReleased(code))
self.emitEngineEvent("engine__key_released", .{ .key = code });
}
}
}
// ── Mouse buttons ──
if (comptime mouse_events_wanted) {
const pressed_wanted = comptime engineEventWanted("engine__mouse_button_pressed");
const released_wanted = comptime engineEventWanted("engine__mouse_button_released");
for (std.enums.values(input_types.MouseButton)) |b| {
const code: u32 = @intCast(@intFromEnum(b));
if (pressed_wanted) {
if (Self.Input.isMouseButtonPressed(code))
self.emitEngineEvent("engine__mouse_button_pressed", .{
.button = code,
.x = Self.Input.getMouseX(),
.y = Self.Input.getMouseY(),
});
}
if (released_wanted) {
if (Self.Input.isMouseButtonReleased(code))
self.emitEngineEvent("engine__mouse_button_released", .{
.button = code,
.x = Self.Input.getMouseX(),
.y = Self.Input.getMouseY(),
});
}
}
}
// ── Gamepad connect / disconnect (engine-side edge diff) ──
if (comptime gamepad_events_wanted) {
const connected_wanted = comptime engineEventWanted("engine__gamepad_connected");
const disconnected_wanted = comptime engineEventWanted("engine__gamepad_disconnected");
var gi: u32 = 0;
while (gi < max_tracked_gamepads) : (gi += 1) {
const now = Self.Input.isGamepadAvailable(gi);
const was = self.prev_gamepad_connected[gi];
if (now and !was) {
if (connected_wanted)
self.emitEngineEvent("engine__gamepad_connected", .{ .id = gi });
} else if (!now and was) {
if (disconnected_wanted)
self.emitEngineEvent("engine__gamepad_disconnected", .{ .id = gi });
}
self.prev_gamepad_connected[gi] = now;
}
}
}
There was a problem hiding this comment.
Done — switched all three scans to runtime for/while over the comptime enum-value slices, so emitEngineEvent has one call site per category instead of ~114. Dropped @setEvalBranchQuota entirely; the *_wanted emit gates stay comptime (hoisted to const), so an unused category still folds away. Tests green.
…606) emitEngineEvent inlined per-key under inline for, bloating the binary and needing @setEvalBranchQuota(20_000). Plain runtime for over the comptime enum-value slices gives one call site per category; the emit-wanted gates stay comptime. Quota bump removed.
There was a problem hiding this comment.
Pull request overview
This PR adds engine-hosted, cross-backend input edge events (keyboard, mouse buttons, and gamepad connect/disconnect) to the existing buffered OnEvent flow mechanism by scanning the unified InputInterface during Game.tick.
Changes:
- Added six new
engine.Eventsvariants for key press/release, mouse button press/release (with cursor position), and gamepad connect/disconnect. - Implemented
scanInputEvents()insrc/game.zigto emit the new events (with per-category comptime gating and gamepad connect diffing). - Added a dedicated unit test suite covering input event emission and the “
GameEvents = voidemits nothing/compiles” guarantee.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/input_events_test.zig | Adds a controllable input stub + recorder to validate all new input events and comptime gating behavior. |
| test/engine_events_test.zig | Extends the existing variant-set assertion to include the new engine.Events input event shapes. |
| src/root.zig | Declares the new engine.Events payload structs for input events. |
| src/game.zig | Adds per-frame input edge scanning/emission (comptime-gated) and gamepad availability diff state. |
| build.zig | Registers the new input events test file in the test suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Lets flows react to user input via the existing
OnEvent(labelle-gui#208, Option B). Engine-only — the assembler auto-discoversengine.Events, so flows reach these with no codegen/assembler change.Events (added to
engine.Events)key_pressed { key: u32 }/key_released { key: u32 }mouse_button_pressed { button: u32, x: f32, y: f32 }/mouse_button_released { … }gamepad_connected { id: u32 }/gamepad_disconnected { id: u32 }A flow handles e.g.
OnEvent { name: "engine.key_pressed" }and branches onkey.Cross-backend by construction
The per-frame edge scan (
scanInputEvents, run at the tail oftick) queries only the unifiedInputInterface(Self.Input.isKeyPressed/isKeyReleased/isMouseButton*/getMouseX/Y/isGamepadAvailable) — never a backend's native input/event API. raylib/sokol/SDL each implement that interface; optional methods fall back tofalse, so an event simply doesn't fire on a backend lacking support (e.g. gamepad on sokol/SDL).Keyboard/mouse edges reuse the backend's own pressed/released state (no engine bookkeeping). Gamepad connect/disconnect has no "this-frame" query, so the engine diffs
isGamepadAvailable(i)against aprev_gamepad_connected[4]field and emits on transitions (fires once, not while held connected).Zero-cost when unused
Each scan category is wrapped in
if (comptime <cat>_wanted)(mirroringemitEngineEvent's@hasField(GameEvents, tag)gate), and each emit is per-tag gated — so a game whose flows don't listen has the entire enum scan comptime-eliminated. AGameEvents = voidgame compiles and emits nothing (tested).Timing
Emitted at the tail of
tick(afterpost_tick); buffers with the frame's lifecycle events and drains on the main loop's post-tickdispatchEvents— same frame.Tests
test/input_events_test.zig(8, controllable input stub + recorder): key down/up-edge codes,key_nullskipped, mouse press/release with x/y, gamepad connect fires once / disconnect, andGameEvents = voidemits nothing + compiles.zig build testgreen;zig buildclean.Deferred (per #208):
mouse_moved(high-frequency), gamepad buttons/axes, touch. Parent: labelle-gui#187 / #208.