Week 39: reworking the input system
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:
- the code was hard to navigate due to nested generics
- There were no edge information on events so i couldn’t know if let’s say the attack button was just pressed or repeated
- events were not properly timed
- And it’s stateful everywhere…
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:
- the action driven using a unified event type that has a strength between −1 and 1.
- the keymap format although i don’t care about the order and input captured by the UI should be unconditionally dropped and it’s a bit too verbose.
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:
- Keep the good part (iterate on them): action driven, the keymap
- have an event pipeline that is explicit and better
- Both state and event streams should be easily used
- Everything has a timestamp
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:
- frame 1 start
- press A
- release A
- frame 2 start
Would be observed from game.tick as:
- frame 1: A released steady
- frame 2: A falling
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();
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.