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. If a press is dropped, the game ignores the player. If it is repeated, 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. This is how the
// game is 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;
}
}
/// Give the latched presses to one tick, then clear 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. This works because take clears the latch.
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 all the control a phone
needs. It took one extra branch in poll, because the simulation already works
in lanes and not 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();
Mute only affects the audio device. Nothing in sim/ knows sound exists, and a
mute key inside the simulation would change that.