Skip to content

feat: engine input events (key/mouse-button/gamepad) for flows#606

Merged
apotema merged 2 commits into
mainfrom
feat/input-events
Jun 9, 2026
Merged

feat: engine input events (key/mouse-button/gamepad) for flows#606
apotema merged 2 commits into
mainfrom
feat/input-events

Conversation

@apotema

@apotema apotema commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Lets flows react to user input via the existing OnEvent (labelle-gui#208, Option B). Engine-only — the assembler auto-discovers engine.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 on key.

Cross-backend by construction

The per-frame edge scan (scanInputEvents, run at the tail of tick) queries only the unified InputInterface (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 to false, 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 a prev_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) (mirroring emitEngineEvent'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. A GameEvents = void game compiles and emits nothing (tested).

Timing

Emitted at the tail of tick (after post_tick); buffers with the frame's lifecycle events and drains on the main loop's post-tick dispatchEvents — same frame.

Tests

test/input_events_test.zig (8, controllable input stub + recorder): key down/up-edge codes, key_null skipped, mouse press/release with x/y, gamepad connect fires once / disconnect, and GameEvents = void emits nothing + compiles. zig build test green; zig build clean.

Deferred (per #208): mouse_moved (high-frequency), gamepad buttons/axes, touch. Parent: labelle-gui#187 / #208.

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.
@cursor

cursor Bot commented Jun 9, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New per-frame input polling on the main tick path when flows subscribe to input events; behavior depends on backend InputInterface edge semantics and gamepad diffing, though unused variants compile out entirely.

Overview
Adds six engine input events (key_pressed / key_released, mouse button press/release with cursor position, gamepad connect/disconnect) on engine.Events, wired for flows via existing OnEvent names like engine.key_pressed.

Each frame, Game.tick now runs scanInputEvents at the end of the active (non-paused) tick body: it polls only the unified InputInterface, dual-emits through emitEngineEvent, and buffers with other lifecycle events for dispatchEvents in the same frame. Keyboard and mouse use backend edge queries; gamepad plug/unplug is synthesized by diffing isGamepadAvailable against new prev_gamepad_connected (four slots).

Scanning is comptime-gated per category and per variant (@hasField on GameEvents), so games that do not declare those variants pay no per-frame scan cost. test/input_events_test.zig covers edges, payloads, gamepad one-shot connect, and GameEvents = void; engine_events_test asserts the new variants exist.

Reviewed by Cursor Bugbot for commit fd7b9a5. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/game.zig
Comment on lines +1758 to +1822
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;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
                }
            }
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.Events variants for key press/release, mouse button press/release (with cursor position), and gamepad connect/disconnect.
  • Implemented scanInputEvents() in src/game.zig to 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 = void emits 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.

@apotema apotema merged commit 7b3dae8 into main Jun 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants