Input
The simulation runs at a fixed 120 Hz. The window runs at whatever the display does. Those do not divide evenly, so a rendered frame may owe the simulation zero ticks, or one, or three.
A press has to reach exactly one of them. Drop it and the game eats inputs. Repeat it and one tap crosses three lanes.
pub const Latch = struct {
left: bool = false,
right: bool = false,
confirm: bool = false,
/// Called once per rendered frame, before the tick loop.
pub fn poll(self: *Latch) void {
if (rl.IsKeyPressed(rl.KEY_LEFT) or
rl.IsKeyPressed(rl.KEY_A)) self.left = true;
if (rl.IsKeyPressed(rl.KEY_RIGHT) or
rl.IsKeyPressed(rl.KEY_D)) self.right = true;
if (rl.IsKeyPressed(rl.KEY_SPACE) or
rl.IsKeyPressed(rl.KEY_ENTER)) self.confirm = true;
// Touch and mouse: tap a side of the screen to steer, which is how the
// game is actually played on a phone.
if (rl.IsMouseButtonPressed(rl.MOUSE_BUTTON_LEFT)) {
const x = rl.GetMousePosition().x;
if (x < @as(f32, @floatFromInt(rl.GetScreenWidth())) * 0.5) {
self.left = true;
} else {
self.right = true;
}
self.confirm = true;
}
}
/// Hand the latched presses to one tick and forget them.
pub fn take(self: *Latch) sim.Input {
const input: sim.Input = .{
.left = self.left,
.right = self.right,
.confirm = self.confirm,
};
self.* = .{};
return input;
}
pub fn pending(self: *const Latch) bool {
return self.left or self.right or self.confirm;
}
};poll runs once per rendered frame, before the tick loop. take hands the
latched presses to one tick and clears them.
Why the fields are booleans and not key states
IsKeyPressed is true on the frame a key goes down and false while it is held.
That is the behaviour the game wants. A held D should move one lane, not slide
across the board at a hundred lanes a second.
The simulation says so in its own type:
/// Edge-triggered: each field means "pressed during this tick", not "held".
/// A held key must not slide the player across the board.
pub const Input = struct {
left: bool = false,
right: bool = false,
confirm: bool = false,
pub const none: Input = .{};
};And a test holds the compiler to it:
test "a held direction moves exactly one lane" {
// Input is edge-triggered. If a held key were read as a press every tick,
// the player would cross the board in two frames.
var w: World = .init(1);
w.start();
const start_lane = w.player.lane;
w.step(.{ .right = true });
try std.testing.expectEqual(start_lane + 1, w.player.lane);
for (0..60) |_| w.step(.none);
try std.testing.expectEqual(start_lane + 1, w.player.lane);
}A frame that owes no ticks
If a frame produces no ticks, take is never called and the latch keeps its
presses for the next frame. A tap between two ticks is remembered rather than
lost.
If a frame produces three ticks, the first gets the press and the other two get
nothing. That is what take clearing itself buys.
Touch
The same poll reads the mouse, and splits the window down the middle.
Tapping the left half steers left, tapping the right half steers right, and either counts as the confirm that starts a run. That is the whole control scheme on a phone, and it needed four lines because the simulation already thought in lanes rather than in keys.
What the platform layer does not do
Mute is a keypress, and it never reaches the simulation:
if (rl.IsKeyPressed(rl.KEY_M)) self.audio.toggleMute();
Muting is not a rule of the game. Nothing in sim/ knows sound exists, and
adding a key for it would have been the first crack in that.