Week 39: reworking the input system

· weekly · input

This is the start of the dev notes. Weekly. Some weeks will be quite empty, some weeks will be full of stuff. I will try to make sure I keep a schedule. Try is the important word here.

A new input module.

The old input system had flaws:

So a simple attack event should be dealt with something like that: (note the ungodly .waitRelease state)

switch (self.attack_state) {
    .idle => {
        if (input.getBool(.attack_light)) {
            logger.debug("attack light", .{});
            self.attack_state = .{ .attack = current_tick };
        }
    },
    .attack => |start| {
        if (start.to(current_tick) >= config.player.attack.attack_ticks) {
            logger.debug("end of attack", .{});
            self.attack_state = .waitRelease;
        }
    },
    .waitRelease => {
        if (!input.getBool(.attack_light)) self.attack_state = .idle;
    },
}

But a few things worked quite well:

 key = [
  { filter = { captured = true }; action = .drop },
  { filter = { logical = .w }; action = { map_to = .player_forward } },
  ...
  { filter = { logical= .z }; action = { map_to = .player_attack_light } },
]

gamepad_axis = [
  { filter = { captured = true }; action = .drop },
    ...
  { filter = { axis = .left_x }; action = { map_to = .player_right } },
]

gamepad_button = [
  { filter = { captured = true }; action = .drop },
  { filter = { button = .right_shoulder }; action = { map_to = .player_attack_light } },
]

So bim bam boom, let’s rework that.

What i want from the new system:

There could be multiple kinds of actions: menu, gameplay, etc.

To deal with edges and state, ActionState and EventState get a tickEnd function that after a frame transition rising/falling edges to steady states.

It’s not rocket science but it does what I want.

Now the player attack code is nicer to get right, harder to get wrong and there is no ugly waitRelease: ⊕

// In onEvent
switch (event) {
    .player_attack_light => |payload| if (payload.edge() == .rising) {
        switch (self.*) {
            .idle => {
                logger.debug("attack light", .{});
                self.* = .{ .attack = now };
            },
            .attack => {},
        }
    }
}

// In tick:
switch (self.*) {
    .idle => {},
    .attack => |begin| {
        if (begin.durationTo(now).nanoseconds >= step_ns * @as(i96, @intCast(config.attack_ticks))) {
            logger.debug("end of attack", .{});
            self.* = .idle;
        }
    },
}

And the keymap is a bit less verbose

key = [
    ...
  { filter = { logical = .z }; action = .player_attack_light },
]

gamepad_axis = [
    ...
  { filter = { axis = .left_x }; action = .player_right },
]

gamepad_button = [
  { filter = { button = .right_shoulder }; action = .player_attack_light },
]

Known limitations and future work: The implementation was not meant to be perfect and is still not perfect. The main issue i know of right now is that taps within a frame are not supported by the state API: the event sequence:

  1. frame 1 start
  2. press A
  3. release A
  4. frame 2 start

Would be observed from game.tick as:

And thus checking for a rising signal would not detect this pattern.

The solution I picked to fix that is simple: don’t use the state API to detect rising edge for burstable input. Another solution would be to latch a rising boolean until tickEnd but I don’t know yet. Maybe removing the edge within the state API is a better solution.

Time as an event, and replay

During the implementation of this system, I got to crave a fully testable game loop by replaying a sequence of events. A nice addition to a non-existent game!

The current event stream is nearly good enough to do so. The only issue is that frame times are driven by the engine directly, separately. The solution I went for it that a new frame is a synthetic “frame barrier” event that is used to tick the game clock. Like that i can replay a session at any speed with a fully deterministic output 1. ⊕

while (self.input_stream.next()) |input| {
    switch (input) {
        .frame => |now| {
            self.time_driver.advanceTo(now);

            while (self.time_driver.root_clock.consume(fixed_step)) |_| {
                self.game.tick(&self.input_state, fixed_step, now);
                self.input_state.tickEnd();
            }
        },
        .action => |event| {
            self.input_state.accumulate(event);
            self.game.onEvent(event);
        },
    }
}

This “week” in articles, PDFs, commits and more.

State of SIMD Rust in 2026 by Sergey “Shnatsel” Davidoff

A very nice survey that also introduces some concept that are often forgotten in first introductions to SIMD (like multiversioning) and that goes very far. I love that the author goes deep on the subject and tries to fix the rust landscape. A dev to follow i believe. I added their blog to my RSS feed.

I also love the multiple reference to dig further. Here a very small subset:

Misc

A few nuggets sparked from reading Time Measurement in Game Programming by Adam Sawicki and exploring about time.

First this code snippet:

  // We don't need to cache QPF as it's internally just a memory read to KUSER_SHARED_DATA
  // (a read-only page of info updated and mapped by the kernel to all processes):
  // https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-kuser_shared_data
  // https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntexapi_x/kuser_shared_data/index.htm
  const qpf = windows.QueryPerformanceFrequency();
Source: https://github.com/ziglang/zig/pull/26021/changes

I knew that linux did the same with vDSO but never explored the thing. I will try to read a bit on the subject later.

Then a tip from J. Gregory (Game engine architecture, 2015, ch. 8.5.5): frames time should be clamped so that breakpoints (and also unwanted lag spikes!) don’t break the loop.

// (this is pseudo-code)
loop {
  const now = io.time.now();
  const dt = (now - before).clamp(0, 30ms);
  before = now;
  ...
}

Bibliography

  • Game engine architecture — J. Gregory, 2015, CRC Press, Taylor & Francis Group.