⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1941+71115f0abWhat's newOn an older Zig?

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.

examples/lane-dodger/src/render/draw.zigView
/// 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.

That matters more than it looks. If the view stretched to fill the window, a wide window would show blocks earlier and resizing would become a strategy.

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

examples/lane-dodger/src/render/draw.zigframe
pub fn frame(

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

examples/lane-dodger/src/render/draw.zigdrawRoad
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 legible 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

examples/lane-dodger/src/render/draw.zigdrawEntities
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 pickup the player cannot see is a pickup
                // they will not go for.
                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, and that floor is not cosmetic. An earlier version swept the full range, so the coin turned edge on and disappeared for a few frames. A pickup the player cannot see is a pickup they will not go for.

The player

examples/lane-dodger/src/render/draw.zigdrawPlayer
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;

    // Lean into the turn. Reads as intent, and makes the slide feel driven
    // rather than dragged.
    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 and it is most of what makes the movement read as deliberate rather than dragged.

Colours are plain data

examples/lane-dodger/src/render/palette.zigColor
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);
    }
};

No raylib type anywhere in that file. draw.zig converts at the edge:

examples/lane-dodger/src/render/draw.zigcolor
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. This decides that means eight amber dots flying outwards.

examples/lane-dodger/src/render/particles.zigSystem
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 simply dropped.
    /// A dropped spark is invisible; stalling the frame to make room is not.
    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. Writing that pool generically meant the renderer got fixed-capacity storage with no allocator for free.

A full pool drops new particles rather than making room. A dropped spark is invisible. Stalling a frame to fit one in is not.

examples/lane-dodger/src/render/particles.zigburst
    /// 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.

examples/lane-dodger/src/render/draw.zigshadowed
/// 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.