Drawing It
The simulation works in a fixed 360 by 640 field. The window is whatever size the reader dragged it to. One struct maps between them.
/// Field units to screen pixels.
pub const View = struct {
scale: f32,
origin_x: f32,
origin_y: f32,
shake_x: f32 = 0,
shake_y: f32 = 0,
pub fn init(screen_w: f32, screen_h: f32) View {
const scale = @min(screen_w / config.field_w, screen_h / config.field_h);
return .{
.scale = scale,
.origin_x = (screen_w - config.field_w * scale) * 0.5,
.origin_y = (screen_h - config.field_h * scale) * 0.5,
};
}
pub fn x(self: View, field_x: f32) f32 {
return self.origin_x + field_x * self.scale + self.shake_x;
}
pub fn y(self: View, field_y: f32) f32 {
return self.origin_y + field_y * self.scale + self.shake_y;
}
pub fn len(self: View, value: f32) f32 {
return value * self.scale;
}
fn rect(self: View, cx: f32, cy: f32, half_w: f32, half_h: f32) rl.Rectangle {
return .{
.x = self.x(cx - half_w),
.y = self.y(cy - half_h),
.width = self.len(half_w * 2),
.height = self.len(half_h * 2),
};
}
};The scale is the smaller of the two ratios, so the field is letterboxed. A wider window gets bars on the sides. It does not get more road.
If the view stretched to fill the window, a wide window would show blocks earlier, and a player could gain an advantage by resizing.
shake_x and shake_y are added at the end of the transform, so screen shake
moves the whole field and costs nothing anywhere else.
A frame
pub fn frame(
world: *const sim.World,
system: *particles.System,
view: View,
alpha: f32,
/// Total distance scrolled, used only to animate the road stripes.
scroll: f32,
/// False behind the title screen, where the score on show would be the
/// attract mode's rather than the player's.
show_hud: bool,
) void {
rl.ClearBackground(color(palette.background));
drawRoad(view, scroll);
drawEntities(world, view, alpha);
drawParticles(system, view);
if (world.phase != .dead) drawPlayer(world, view, alpha);
if (show_hud) drawHud(world, view);
}The order is back to front: road, entities, particles, player, HUD. The world is read and never written. Nothing in this file can change the outcome of a run.
The road
fn drawRoad(view: View, scroll: f32) void {
const road = view.rect(
config.field_w * 0.5,
config.field_h * 0.5,
config.field_w * 0.5,
config.field_h * 0.5,
);
rl.DrawRectangleRec(road, color(palette.road));
// Lane separators.
for (1..config.lane_count) |i| {
const lane_x = view.x(@as(f32, @floatFromInt(i)) * config.lane_w);
rl.DrawRectangleV(
.{ .x = lane_x - view.len(1), .y = view.y(0) },
.{ .x = view.len(2), .y = view.len(config.field_h) },
color(palette.lane_line),
);
}
// Dashes scrolling down each separator, so speed is visible even when the
// field happens to be empty.
const period: f32 = 80;
const offset = @mod(scroll, period);
for (1..config.lane_count) |i| {
const lane_x = view.x(@as(f32, @floatFromInt(i)) * config.lane_w);
var dash_y: f32 = -period + offset;
while (dash_y < config.field_h) : (dash_y += period) {
rl.DrawRectangleV(
.{ .x = lane_x - view.len(2), .y = view.y(dash_y) },
.{ .x = view.len(4), .y = view.len(36) },
color(palette.stripe),
);
}
}
}The dashes scroll with the distance travelled. They are the only thing on screen that shows speed when the field happens to be empty, and the field is empty more often than you would think in the first few seconds.
The entities
fn drawEntities(world: *const sim.World, view: View, alpha: f32) void {
var it = @constCast(&world.entities).iterator();
while (it.next()) |entry| {
const e = entry.value;
const cx = config.laneCenter(e.lane);
const cy = lerp(e.y_prev, e.y, alpha);
switch (e.kind) {
.block => {
const body = view.rect(cx, cy, config.block_half_w, config.block_half_h);
rl.DrawRectangleRounded(body, 0.28, 6, color(palette.block_dark));
const face = view.rect(
cx,
cy - config.block_half_h * 0.28,
config.block_half_w * 0.9,
config.block_half_h * 0.55,
);
rl.DrawRectangleRounded(face, 0.4, 6, color(palette.block));
const gloss = view.rect(
cx,
cy - config.block_half_h * 0.55,
config.block_half_w * 0.7,
config.block_half_h * 0.14,
);
rl.DrawRectangleRounded(gloss, 1, 6, color(palette.block_top));
},
.coin => {
// A slow spin, faked by squashing the width. The squash bottoms
// out at 55%: a coin that turns fully edge-on disappears for a
// few frames, and a player does not go for a coin they cannot
// see.
const spin = 0.55 + 0.45 * @abs(@cos(cy * 0.028));
const half_w = config.coin_half * spin;
rl.DrawEllipse(
@intFromFloat(view.x(cx)),
@intFromFloat(view.y(cy)),
view.len(half_w),
view.len(config.coin_half),
color(palette.coin_dark),
);
rl.DrawEllipse(
@intFromFloat(view.x(cx)),
@intFromFloat(view.y(cy)),
view.len(half_w * 0.62),
view.len(config.coin_half * 0.62),
color(palette.coin),
);
},
}
}
}Each entity is drawn between its last two positions, using the alpha the
loop hands down. Without that the game visibly
steps at 120 Hz on a faster display.
The coin spin is a squash on the width rather than a rotation. It bottoms out at 55 percent, so the coin never disappears. An earlier version swept the full range, so the coin turned edge on and vanished for a few frames. Players do not go for a coin they cannot see.
The player
fn drawPlayer(world: *const sim.World, view: View, alpha: f32) void {
const cx = lerp(world.player.x_prev, world.player.x, alpha);
const cy = config.player_y;
// Tilt into the turn. The tilt shows where the player means to go, and
// makes the slide look steered instead of pulled.
const drift = (config.laneCenter(world.player.lane) - cx) / config.lane_w;
const lean = std.math.clamp(drift, -1, 1) * config.player_half_w * 0.55;
const shadow = view.rect(cx, cy + config.player_half_h * 0.75, config.player_half_w * 0.9, config.player_half_h * 0.22);
rl.DrawRectangleRounded(shadow, 1, 6, color(palette.background.alpha(0.55)));
const nose: rl.Vector2 = .{ .x = view.x(cx + lean), .y = view.y(cy - config.player_half_h) };
const left: rl.Vector2 = .{ .x = view.x(cx - config.player_half_w), .y = view.y(cy + config.player_half_h) };
const right: rl.Vector2 = .{ .x = view.x(cx + config.player_half_w), .y = view.y(cy + config.player_half_h) };
// raylib wants counter-clockwise winding or the triangle is culled.
rl.DrawTriangle(nose, left, right, color(palette.player));
const core: rl.Vector2 = .{ .x = view.x(cx + lean * 0.5), .y = view.y(cy) };
const core_left: rl.Vector2 = .{ .x = view.x(cx - config.player_half_w * 0.45), .y = view.y(cy + config.player_half_h * 0.7) };
const core_right: rl.Vector2 = .{ .x = view.x(cx + config.player_half_w * 0.45), .y = view.y(cy + config.player_half_h * 0.7) };
rl.DrawTriangle(core, core_left, core_right, color(palette.player_dark));
}The lean is computed from the distance between the target lane and the actual position, which is exactly the gap the previous chapter described. It costs three lines. It is most of what makes the movement look deliberate.
Colours are plain data
pub const Color = struct {
r: u8,
g: u8,
b: u8,
a: u8 = 255,
pub fn alpha(self: Color, value: f32) Color {
const clamped = if (value < 0) 0 else if (value > 1) 1 else value;
return .{ .r = self.r, .g = self.g, .b = self.b, .a = @intFromFloat(clamped * 255) };
}
pub fn mix(a: Color, b: Color, t: f32) Color {
const clamped = if (t < 0) 0 else if (t > 1) 1 else t;
return .{
.r = lerp(a.r, b.r, clamped),
.g = lerp(a.g, b.g, clamped),
.b = lerp(a.b, b.b, clamped),
.a = lerp(a.a, b.a, clamped),
};
}
fn lerp(a: u8, b: u8, t: f32) u8 {
const af: f32 = @floatFromInt(a);
const bf: f32 = @floatFromInt(b);
return @intFromFloat(af + (bf - af) * t);
}
};That file has no raylib types. draw.zig converts at the edge:
fn color(c: palette.Color) rl.Color {
return .{ .r = c.r, .g = c.g, .b = c.b, .a = c.a };
}The reason is the particle system below. It needs colours, and keeping raylib out of it is what lets it be tested without a window.
Particles
Particles are presentation. The simulation reports that a coin was collected. The particle system turns that into ten amber dots flying outwards.
pub const System = struct {
pub const capacity = 256;
const Pool = sim.Pool(Particle, capacity);
pool: Pool,
rng: Rng,
pub fn init(seed: u64) System {
return .{ .pool = .empty, .rng = .init(seed, 0xBEEF) };
}
pub fn clear(self: *System) void {
self.pool.clear();
}
pub fn live(self: *const System) u16 {
return self.pool.live;
}
/// Oldest wins: when the pool is full, new particles are dropped. Nobody
/// sees a dropped spark, but a frame that stalls to make room is visible.
fn emit(self: *System, p: Particle) void {
_ = self.pool.create(p);
}
pub fn update(self: *System, dt: f32) void {
var it = self.pool.iterator();
while (it.next()) |entry| {
const p = entry.value;
p.life -= dt;
if (p.life <= 0) {
self.pool.destroy(entry.handle);
continue;
}
p.vy += p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
}
pub fn iterator(self: *System) Pool.Iterator {
return self.pool.iterator();
}
/// A ring of sparks, used for coins.
pub fn burst(self: *System, x: f32, y: f32, count: usize, color: Color, speed: f32) void {
for (0..count) |i| {
const turn = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const angle = turn * std.math.tau + self.rng.float() * 0.4;
const magnitude = speed * (0.6 + self.rng.float() * 0.8);
const life = 0.35 + self.rng.float() * 0.3;
self.emit(.{
.x = x,
.y = y,
.vx = @cos(angle) * magnitude,
.vy = @sin(angle) * magnitude,
.life = life,
.life_max = life,
.size = 3 + self.rng.float() * 3,
.gravity = 420,
.color = color,
});
}
}
/// A wider, slower, heavier burst for the crash.
pub fn debris(self: *System, x: f32, y: f32) void {
for (0..28) |_| {
const angle = (self.rng.float() - 0.5) * std.math.pi * 1.6 - std.math.pi / 2.0;
const magnitude = 120 + self.rng.float() * 340;
const life = 0.5 + self.rng.float() * 0.6;
self.emit(.{
.x = x,
.y = y,
.vx = @cos(angle) * magnitude,
.vy = @sin(angle) * magnitude,
.life = life,
.life_max = life,
.size = 3 + self.rng.float() * 5,
.gravity = 900,
.color = if (self.rng.chance(0.5)) palette.block else palette.block_top,
});
}
}
/// A short horizontal smear behind the player when they commit to a lane.
pub fn dust(self: *System, x: f32, y: f32, direction: f32) void {
for (0..6) |_| {
const life = 0.18 + self.rng.float() * 0.16;
self.emit(.{
.x = x,
.y = y + (self.rng.float() - 0.5) * 30,
.vx = -direction * (60 + self.rng.float() * 120),
.vy = (self.rng.float() - 0.5) * 40,
.life = life,
.life_max = life,
.size = 2 + self.rng.float() * 3,
.gravity = 0,
.color = palette.player_dark,
});
}
}
/// Two vertical streaks marking a block that went by close.
pub fn graze(self: *System, x: f32, y: f32) void {
for (0..8) |_| {
const life = 0.25 + self.rng.float() * 0.2;
self.emit(.{
.x = x + (self.rng.float() - 0.5) * 40,
.y = y + (self.rng.float() - 0.5) * 50,
.vx = (self.rng.float() - 0.5) * 60,
.vy = 260 + self.rng.float() * 200,
.life = life,
.life_max = life,
.size = 2 + self.rng.float() * 2,
.gravity = 0,
.color = palette.good,
});
}
}
};It uses the same pool the entities use, with a different element type and a capacity of 256.
A full pool drops new particles. It does not stall a frame to make room, because a stalled frame is visible and a missing spark is not.
/// A ring of sparks, used for coins.
pub fn burst(self: *System, x: f32, y: f32, count: usize, color: Color, speed: f32) void {
for (0..count) |i| {
const turn = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count));
const angle = turn * std.math.tau + self.rng.float() * 0.4;
const magnitude = speed * (0.6 + self.rng.float() * 0.8);
const life = 0.35 + self.rng.float() * 0.3;
self.emit(.{
.x = x,
.y = y,
.vx = @cos(angle) * magnitude,
.vy = @sin(angle) * magnitude,
.life = life,
.life_max = life,
.size = 3 + self.rng.float() * 3,
.gravity = 420,
.color = color,
});
}
}Because none of this touches raylib, the behaviour is checked headlessly: particles expire and free their slots, a full pool stays inside capacity, and gravity pulls them down.
Text over a moving field
The HUD sits at the top of the field, which is exactly where blocks enter. The score spends part of every run on top of a bright red rectangle.
/// Text with a hard drop shadow.
///
/// The HUD sits over the top of the field, which is exactly where blocks enter,
/// so the score spends part of every run on top of a bright red rectangle. A
/// shadow is cheaper than reserving a strip of the playfield for the HUD, and
/// keeps the whole field playable.
fn shadowed(text: [*:0]const u8, px: i32, py: i32, size: i32, c: palette.Color) void {
const offset = @max(@divTrunc(size, 14), 1);
rl.DrawText(text, px + offset, py + offset, size, color(palette.background.alpha(0.75)));
rl.DrawText(text, px, py, size, color(c));
}A one pixel shadow is cheaper than reserving a strip of the playfield for the HUD, and it keeps the whole field playable.