⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.253+16d31ceedWhat's newOn an older Zig?Blog

Handles, Not Pointers

Updated · Code verified against Zig 0.18.0-dev.253+16d31ceed

Blocks and coins are created above the screen and destroyed after they leave the bottom. That happens several times a second for as long as the game runs.

Storage is a fixed array

A run of any length allocates once at startup, then never again. There is no allocator in the simulation, which is part of how it imports nothing.

Forty-eight slots is more than the field can hold. At the tightest spacing and the highest speed there are about three rows on screen, with at most three entities each, so nine or so at once. A test asserts the peak stays under capacity and above six, so a change that quietly stops spawning shows up.

An index is not enough

The obvious way to refer to an entity is its index. That leads to a bug that is very hard to reproduce.

An event says “coin 12 was collected”. A frame later, slot 12 has been freed and given to a newly spawned block. Anything still holding 12 now reads a block and believes it is a coin. The program does not crash. The score is wrong about once an hour, usually when nobody is watching.

Storing a pointer instead of an index does not help, since the slot is reused either way.

Generational handles

A handle carries the slot index and the generation that slot was on when the handle was made. Freeing a slot bumps its generation, so every handle to the old occupant stops resolving.

examples/lane-dodger/src/sim/pool.zigPool
pub fn Pool(comptime T: type, comptime capacity_in: u16) type {
    return struct {
        const Self = @This();

        pub const capacity = capacity_in;

        pub const Handle = struct {
            index: u16,
            generation: u32,

            /// A handle that never resolves. Generation 0 is never handed out,
            /// because live slots start at generation 1.
            pub const none: Handle = .{ .index = 0, .generation = 0 };

            pub fn eql(a: Handle, b: Handle) bool {
                return a.index == b.index and a.generation == b.generation;
            }
        };

        const Slot = struct {
            value: T,
            /// Odd while the slot is live, even while it is free.
            generation: u32,
            /// Index of the next free slot, valid only while this one is free.
            next_free: u16,
        };

        slots: [capacity]Slot,
        free_head: u16,
        live: u16,

        pub const empty = init: {
            var self: Self = .{
                .slots = undefined,
                .free_head = 0,
                .live = 0,
            };
            for (&self.slots, 0..) |*slot, i| {
                slot.* = .{
                    .value = undefined,
                    .generation = 0,
                    .next_free = @intCast(i + 1),
                };
            }
            break :init self;
        };

        /// Returns null when the pool is full. Callers decide what a full pool
        /// means; the spawner treats it as back-pressure and skips the spawn,
        /// which is always safe because the course stays solvable.
        pub fn create(self: *Self, value: T) ?Handle {
            if (self.free_head >= capacity) return null;
            const index = self.free_head;
            const slot = &self.slots[index];
            self.free_head = slot.next_free;
            slot.generation += 1; // even -> odd: now live
            slot.value = value;
            self.live += 1;
            return .{ .index = index, .generation = slot.generation };
        }

        pub fn destroy(self: *Self, handle: Handle) void {
            const slot = self.resolve(handle) orelse return;
            slot.generation += 1; // odd -> even: now free
            slot.next_free = self.free_head;
            self.free_head = handle.index;
            self.live -= 1;
        }

        pub fn get(self: *Self, handle: Handle) ?*T {
            const slot = self.resolve(handle) orelse return null;
            return &slot.value;
        }

        pub fn contains(self: *const Self, handle: Handle) bool {
            if (handle.index >= capacity) return false;
            const slot = &self.slots[handle.index];
            return slot.generation == handle.generation and slot.generation % 2 == 1;
        }

        fn resolve(self: *Self, handle: Handle) ?*Slot {
            if (handle.index >= capacity) return null;
            const slot = &self.slots[handle.index];
            if (slot.generation != handle.generation) return null;
            if (slot.generation % 2 == 0) return null;
            return slot;
        }

        pub const Entry = struct { handle: Handle, value: *T };

        pub const Iterator = struct {
            pool: *Self,
            index: u16 = 0,

            pub fn next(self: *Iterator) ?Entry {
                while (self.index < capacity) {
                    const i = self.index;
                    self.index += 1;
                    const slot = &self.pool.slots[i];
                    if (slot.generation % 2 == 1) return .{
                        .handle = .{ .index = i, .generation = slot.generation },
                        .value = &slot.value,
                    };
                }
                return null;
            }
        };

        pub fn iterator(self: *Self) Iterator {
            return .{ .pool = self };
        }

        pub fn clear(self: *Self) void {
            var it = self.iterator();
            while (it.next()) |entry| self.destroy(entry.handle);
        }
    };
}

The generation is odd while a slot is live and even while it is free. One counter answers both “which occupant” and “is the slot in use”. Generation zero is never handed out, so a zeroed handle is a null handle.

create returns an optional rather than asserting. A full pool is not an error here. The spawner treats it as back pressure and skips that spawn. That is safe because the course stays solvable either way.

The test names the bug:

examples/lane-dodger/src/sim/pool.ziga stale handle does not resolve to the slot's new occupant
test "a stale handle does not resolve to the slot's new occupant" {
    var pool: TestPool = .empty;
    const old = pool.create(1).?;
    pool.destroy(old);
    const new = pool.create(2).?;
    // The pool reuses the freed slot. This is what the pool is for.
    try std.testing.expectEqual(old.index, new.index);
    // The stale handle must not see the new value.
    try std.testing.expect(pool.get(old) == null);
    try std.testing.expectEqual(@as(i32, 2), pool.get(new).?.*);
}

The first assertion is there to prove the slot really was reused. Without it the test could pass because the pool handed out a fresh slot, and that case does not test the bug.

One pool, two users

The pool is generic, so other code can use it. The particle system uses the same pool with a capacity of 256 and a different element type, even though particles belong to the renderer and not the simulation. Writing it once gave the renderer fixed-capacity storage with no allocator, at no extra cost.

Events

The simulation reports what happened. It does not know what anything will do about it.

examples/lane-dodger/src/sim/sim.zigEvent
/// What the simulation did this tick, for the parts of the program that react
/// to it: particles, screen shake, sound. The simulation does not know those
/// exist, which is why adding a sound cannot change the physics.
pub const Event = union(enum) {
    started,
    lane_changed: struct { from: u8, to: u8 },
    coin: struct { x: f32, y: f32, combo: u32, points: u32 },
    coin_missed,
    near_miss: struct { x: f32, y: f32 },
    crashed: struct { x: f32, y: f32 },
};

Events accumulate in a fixed buffer, and the caller drains it once per frame. The buffer counts what it had to drop. A test asserts that count is zero across forty thousand ticks. A truncated event stream would show up as occasional missing particles and nothing else.

The whole reaction to a tick is one function:

examples/lane-dodger/src/main.zigreact
/// Turn what the simulation reported into what the player sees and feels.
/// Nothing here can change the outcome of the run.
fn react(
    world: *const sim.World,
    system: *particles.System,
    shake: *Shake,
    audio: ?*Audio,
) void {
    if (audio) |sound| {
        for (world.events.slice()) |event| sound.onEvent(event);
    }
    for (world.events.slice()) |event| switch (event) {
        .coin => |c| {
            system.burst(c.x, c.y, 10, palette.coin, 190);
            shake.kick(1.5 + @as(f32, @floatFromInt(c.combo)) * 0.4);
        },
        .near_miss => |n| {
            system.graze(n.x, n.y);
            shake.kick(3);
        },
        .crashed => |c| {
            system.debris(c.x, c.y);
            shake.kick(16);
        },
        .lane_changed => |l| {
            const direction: f32 = if (l.to > l.from) 1 else -1;
            system.dust(world.player.x, config.player_y, direction);
        },
        .coin_missed, .started => {},
    };
}

A coin becomes ten amber dots and a small kick of screen shake. A crash becomes debris. None of that is visible to the simulation.

What this split buys

Sound was added after the game was already playable. It did not change any rule of the game, because it reads the same events the particles read.

examples/lane-dodger/src/platform/audio.zigonEvent
    /// The one entry point the game uses. Takes the same events the particles
    /// and the screen shake take.
    pub fn onEvent(self: *Audio, event: sim.Event) void {
        switch (event) {
            .coin => |c| {
                // Each combo step raises the pitch of one recording, so there
                // are not eight recordings.
                // A semitone is 2^(1/12); this is a little under one per step,
                // so a full combo lands about a fifth above where it started.
                const step = std.math.pow(f32, 2.0, @as(f32, @floatFromInt(c.combo - 1)) / 14.0);
                self.cue(.coin, 0.85, step);
            },
            .near_miss => self.cue(.near_miss, 0.6, 1),
            .lane_changed => self.cue(.lane, 0.5, 1),
            .crashed => self.cue(.crash, 1, 1),
            .started => self.cue(.start, 0.8, 1),
            .coin_missed => {},
        }
    }

Event systems are easy to over-build. Six variants in a tagged union, a fixed buffer and one loop are enough here. The test of the design is whether adding a reaction means changing the code that caused it. Here it does not. The same property lets the difficulty tests run the entire game with no renderer and no audio device.