Sprite type and a blit routine written from scratch, with edge clipping so a sprite half-way off the screen never reads or writes memory out of bounds;[]const slices, a sentinel index and checked indexing make the whole thing genuinely hard to misuse;Framebuffer, Rgba and clipping setPixel/getPixel from episode 156, and the DoubleBuffer from episode 161 -- today we stamp sprites into a back buffer and present it;errdefer (episodes 7 and 26), and the testing habits from episode 12;Learn Zig Series):Last episode closed on a promise. We can now animate without flicker and without tearing, paced to the screen's heartbeat -- but every frame is still assembled from scratch, one pixel and one polygon at a time. That is fine for a single spinning triangle and completely hopeless for an actual game, where a single screen might hold a player, a dozen enemies, a scattering of coins, and a whole landscape stretching behind all of them. Nobody rebuilds a brick wall out of individual line-draws sixty times a second. Instead you draw each thing once into a small image, and from then on you stamp that image wherever it needs to go. Those small pre-drawn images are sprites, and when you lay them out in a grid to build a world, that grid is a tile map. Today we build both, from scratch, on top of everything we have. Here we go!
Three exercises last time, all extending the DoubleBuffer and Pacer. Here are my solutions.
Exercise 1 -- a swapCopy alternative. The task was to present by copying the back buffer over the front, instead of flipping the index, and then to say why the flip wins. The copy version is a one-line @memcpy, and it produces exactly the same visible frame. The difference is entirely in cost: swap moves one bit, swapCopy moves the whole screen every present:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
pub fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
pub const DoubleBuffer = struct {
allocator: std.mem.Allocator,
buffers: [2][]Rgba,
width: usize,
height: usize,
back_index: u1,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !DoubleBuffer {
const a = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(a);
const b = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(b);
@memset(a, .{ .r = 0, .g = 0, .b = 0 });
@memset(b, .{ .r = 0, .g = 0, .b = 0 });
return .{ .allocator = allocator, .buffers = .{ a, b }, .width = width, .height = height, .back_index = 0 };
}
pub fn deinit(self: *DoubleBuffer) void {
self.allocator.free(self.buffers[0]);
self.allocator.free(self.buffers[1]);
self.* = undefined;
}
pub fn back(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index], .width = self.width, .height = self.height };
}
pub fn front(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index ^ 1], .width = self.width, .height = self.height };
}
// O(1) present: flip which buffer is front.
pub fn swap(self: *DoubleBuffer) void {
self.back_index ^= 1;
}
// Same visible result, but it moves width*height*4 bytes every frame.
pub fn swapCopy(self: *DoubleBuffer) void {
const src = self.buffers[self.back_index];
const dst = self.buffers[self.back_index ^ 1];
@memcpy(dst, src);
}
};
test "swapCopy shows the same frame as swap, at the cost of a full-screen memcpy" {
var db = try DoubleBuffer.init(std.testing.allocator, 4, 4);
defer db.deinit();
var b = db.back();
b.clear(.{ .r = 7, .g = 0, .b = 0 });
db.swapCopy(); // present by copying rather than flipping
try std.testing.expectEqual(@as(u8, 7), db.front().getPixel(0, 0).?.r);
}
For a 1080p screen that copy is about 8 MB moved per present, sixty times a second, doing nothing but shuffling finished pixels. The index flip does the same job for the price of a single XOR -- which is precisely why every real renderer flips.
Exercise 2 -- frame statistics. Add a running count of presents and a mean-frame-time helper. The count belongs inside markPresented (the one place a present is recorded), and the average is a pure division with a guard against dividing by zero on the very first frame:
const std = @import("std");
pub const Pacer = struct {
frame_ns: u64,
next_deadline_ns: u64,
armed: bool,
presented: u64 = 0,
pub fn init(target_fps: u32) Pacer {
std.debug.assert(target_fps > 0);
return .{ .frame_ns = std.time.ns_per_s / target_fps, .next_deadline_ns = 0, .armed = false };
}
pub fn markPresented(self: *Pacer, now_ns: u64) void {
if (!self.armed) {
self.next_deadline_ns = now_ns + self.frame_ns;
self.armed = true;
} else {
self.next_deadline_ns += self.frame_ns;
}
self.presented += 1;
}
pub fn averageFrameNs(self: Pacer, total_elapsed_ns: u64) u64 {
if (self.presented == 0) return 0;
return total_elapsed_ns / self.presented;
}
};
test "averageFrameNs divides elapsed time by the number of frames presented" {
var pacer = Pacer.init(60);
const frame_ns = std.time.ns_per_s / 60;
pacer.markPresented(0);
pacer.markPresented(frame_ns);
pacer.markPresented(2 * frame_ns);
pacer.markPresented(3 * frame_ns);
// four frames over four frame-intervals of runtime -> the mean is one frame time
try std.testing.expectEqual(frame_ns, pacer.averageFrameNs(4 * frame_ns));
}
Exercise 3 -- detect a dropped frame. A frame is dropped when rendering overran its interval, so at present time you are already past the deadline. A small wrapper reads that condition before advancing the schedule, then reports it. Note the guard on armed: the very first present has no prior deadline, so it can never be "late":
const std = @import("std");
pub const Pacer = struct {
frame_ns: u64,
next_deadline_ns: u64,
armed: bool,
pub fn init(target_fps: u32) Pacer {
std.debug.assert(target_fps > 0);
return .{ .frame_ns = std.time.ns_per_s / target_fps, .next_deadline_ns = 0, .armed = false };
}
pub fn markPresented(self: *Pacer, now_ns: u64) void {
if (!self.armed) {
self.next_deadline_ns = now_ns + self.frame_ns;
self.armed = true;
} else {
self.next_deadline_ns += self.frame_ns;
}
}
// Present, and report whether we blew past the deadline (a dropped frame).
pub fn presentAndReport(self: *Pacer, now_ns: u64) bool {
const missed = self.armed and now_ns > self.next_deadline_ns;
self.markPresented(now_ns);
return missed;
}
};
test "presentAndReport flags only the frame that overran its deadline" {
var pacer = Pacer.init(60);
const frame_ns = std.time.ns_per_s / 60;
try std.testing.expect(!pacer.presentAndReport(1_000)); // first present arms, never "late"
try std.testing.expect(!pacer.presentAndReport(1_000 + frame_ns)); // exactly on time
try std.testing.expect(pacer.presentAndReport(1_000 + 2 * frame_ns + 5_000)); // ran long: dropped
}
Right, exercises done. Now to the sprites.
Strip away the mystique and a sprite is the most boring thing imaginable: a small rectangle of pixels, sitting in memory, that you copy onto a bigger rectangle of pixels (the framebuffer) at some position. The word comes from the 1970s arcade hardware that literally had dedicated circuitry to overlay these little images, but the software idea is the same -- draw once, stamp forever. A player character, a coin, a bush, a single letter of text: all sprites.
The operation of stamping one image onto another has a name that goes back to the same era: a blit (from BLIT, "block transfer"). Here is the whole thing -- a Sprite type and a first, naive blit -- built on exactly the Framebuffer and Rgba we have carried since episode 156:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
pub fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
// A sprite is just a small, read-only image. Note the `[]const Rgba`: you can draw
// FROM a sprite, but the type forbids you from ever accidentally drawing INTO one.
pub const Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
// Stamp a sprite at (dst_x, dst_y). Fully transparent pixels (a == 0) are skipped,
// and setPixel clips anything that lands off the edge of the framebuffer.
pub fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
test "blit stamps opaque pixels and leaves the background under transparent ones" {
var pixels: [4 * 4]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 4, .height = 4 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
// 2x2 sprite: top-left red, top-right transparent, bottom row green
const data = [_]Rgba{
.{ .r = 255, .g = 0, .b = 0 }, .{ .r = 0, .g = 0, .b = 0, .a = 0 },
.{ .r = 0, .g = 255, .b = 0 }, .{ .r = 0, .g = 255, .b = 0 },
};
const sprite = Sprite{ .pixels = &data, .width = 2, .height = 2 };
blit(&fb, sprite, 1, 1);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 1).?.r); // opaque red landed
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(2, 1).?.r); // transparent skipped: still black
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(2, 1).?.g);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 2).?.g); // green row landed
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(2, 2).?.g);
}
test "blit clips a sprite hanging off the top-left corner without touching memory out of bounds" {
var pixels: [3 * 3]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 3, .height = 3 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const data = [_]Rgba{
.{ .r = 10, .g = 0, .b = 0 }, .{ .r = 20, .g = 0, .b = 0 },
.{ .r = 30, .g = 0, .b = 0 }, .{ .r = 40, .g = 0, .b = 0 },
};
const sprite = Sprite{ .pixels = &data, .width = 2, .height = 2 };
blit(&fb, sprite, -1, -1); // only the sprite's bottom-right pixel survives, landing at (0,0)
try std.testing.expectEqual(@as(u8, 40), fb.getPixel(0, 0).?.r);
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(1, 0).?.r); // nothing else drawn
}
Two design decisions are already baked in, and they are the two that matter. The first is transparency. A sprite is a rectangle, but the thing it draws almost never is -- a coin is round, a character has arms and gaps. So we treat a fully transparent pixel (a == 0) as "not part of the shape" and simply skip it, leaving whatever was underneath. This is called color-key or binary transparency, and it is the oldest trick in the book. Nota bene: this is not the same as blending a half-transparent pixel smoothly into the background -- a pixel here is either fully drawn or not drawn at all. Smoothly mixing partial transparency with what is already on screen is a richer topic with its own arithmetic, and we will give it the attention it deserves a little later in this arc.
The second decision is clipping. A sprite near the edge of the screen sticks out, and if you blindly wrote every one of its pixels you would scribble past the end of the framebuffer -- in C, a classic memory-corruption crash. Our naive blit leans on setPixel, which already checks bounds and quietly drops anything off-screen, so the second test can stamp a sprite at (-1, -1) and only the one overlapping pixel lands. Correct, safe, and completely unremarkable -- which is exactly what you want from the busiest routine in a renderer.
Now the clever part. Imagine a side-scrolling platformer with a level that is 1000 cells wide and 100 tall. If you stored a full image for every cell you would drown in memory. But look at a real level: it is the same grass tile, the same brick, the same sky, repeated thousands of times. So do not store images -- store indices. Keep a small tile set (a handful of distinct sprites) and describe the world as a grid where each cell is just a number: "tile 3 here, tile 3 here, tile 0 here". That grid of numbers is the tile map, and it is astonishingly cheap: a 1000x100 world at two bytes per cell is 200 KB of indices, versus hundreds of megabytes of pixels.
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
pub fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
pub const Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
pub const TileSet = struct {
tiles: []const Sprite, // all distinct tiles, indexed by number
tile_w: usize,
tile_h: usize,
};
pub const TileMap = struct {
indices: []const u16, // one tile index per cell, row-major
cols: usize,
rows: usize,
// A sentinel index meaning "this cell is empty -- draw nothing here".
pub const empty_tile: u16 = std.math.maxInt(u16);
pub fn at(self: TileMap, col: usize, row: usize) u16 {
return self.indices[row * self.cols + col];
}
};
fn drawTileMap(fb: *Framebuffer, map: TileMap, set: TileSet, cam_x: i64, cam_y: i64) void {
var row: usize = 0;
while (row < map.rows) : (row += 1) {
var col: usize = 0;
while (col < map.cols) : (col += 1) {
const idx = map.at(col, row);
if (idx == TileMap.empty_tile) continue; // skip holes
// cell (col,row) lives at world pixel (col*tile_w, row*tile_h); subtract the camera
const sx = @as(i64, @intCast(col * set.tile_w)) - cam_x;
const sy = @as(i64, @intCast(row * set.tile_h)) - cam_y;
blit(fb, set.tiles[idx], sx, sy);
}
}
}
test "drawTileMap stamps each non-empty cell at its grid position" {
const red = [_]Rgba{.{ .r = 255, .g = 0, .b = 0 }};
const blue = [_]Rgba{.{ .r = 0, .g = 0, .b = 255 }};
const tiles = [_]Sprite{
.{ .pixels = &red, .width = 1, .height = 1 },
.{ .pixels = &blue, .width = 1, .height = 1 },
};
const set = TileSet{ .tiles = &tiles, .tile_w = 1, .tile_h = 1 };
const e = TileMap.empty_tile;
const cells = [_]u16{ 0, e, 1, e, 1, 0 }; // 3 cols x 2 rows
const map = TileMap{ .indices = &cells, .cols = 3, .rows = 2 };
var pixels: [3 * 2]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 3, .height = 2 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
drawTileMap(&fb, map, set, 0, 0);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(0, 0).?.r); // red at (0,0)
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(1, 0).?.r); // empty cell stays black
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(2, 0).?.b); // blue at (2,0)
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 1).?.b); // blue at (1,1)
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(2, 1).?.r); // red at (2,1)
}
There are two ideas worth pausing on. The first is the camera. A world can be far bigger than the screen, so we render "what the camera sees" by subtracting the camera's world position from every tile's world position -- scroll the camera right by ten pixels and every tile shifts left by ten. That single subtraction is the whole of 2D scrolling. The second is the empty tile sentinel. Not every cell has something in it (sky, gaps, the inside of a cave), so we reserve the largest u16 value as a magic "nothing here" marker and skip those cells entirely. Using a named constant, TileMap.empty_tile, instead of a bare 65535 sprinkled through the code, is the difference between something a reader understands at a glance and something they have to reverse-engineer.
Look again at where Zig quietly stops you from shooting yourself in the foot. A Sprite holds pixels: []const Rgba -- a const slice. That single keyword means the compiler will refuse to let blit (or anyone else) write into a sprite. Sprites are sources, framebuffers are destinations, and the types enforce that split so a fat-fingered swap of arguments becomes a compile error rather than a corrupted asset you chase for an hour. This is the same discipline we relied on for read-only views back in the slices episode, put to work in graphics.
The tile index is a u16, not an enum, on purpose -- a level editor spits out arbitrary numbers and we want to store them compactly -- but we still give the one special value a name and a home (empty_tile), so intent is never guessed. And every access into map.indices or set.tiles goes through normal Zig slice indexing, which is bounds-checked in debug and ReleaseSafe builds. A malformed map that references tile 99 in a set of five will trip a clean, located panic in testing, long before it ships, in stead of silently reading whatever memory happened to sit past the end of the array. That honesty -- costs and risks visible, mistakes caught where they happen -- is the same Zig ethos we leaned on for the DoubleBuffer last episode, just pointed at a different problem.
Our naive blit works, but it does two wasteful things in its hot loop. It calls setPixel for every sprite pixel, paying a per-pixel bounds check even for a sprite sitting comfortably in the middle of the screen, and it visits pixels that are entirely off-screen only to have setPixel throw them away. The fix is to compute the visible overlap rectangle once, up front, and then run a tight inner loop that needs no per-pixel guard at all:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
};
pub const Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
// The naive version, kept here so we can prove the fast one agrees with it.
fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
// Pre-clip the overlap rectangle, then write straight into the buffer -- no per-pixel bounds test.
fn blitClipped(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
const fb_w: i64 = @intCast(fb.width);
const fb_h: i64 = @intCast(fb.height);
// where inside the sprite do we start (left/top clip)?
const start_sx: usize = if (dst_x < 0) @intCast(-dst_x) else 0;
const start_sy: usize = if (dst_y < 0) @intCast(-dst_y) else 0;
if (start_sx >= sprite.width or start_sy >= sprite.height) return; // fully off the left/top
// where do we stop (right/bottom clip)?
var end_sx: usize = sprite.width;
var end_sy: usize = sprite.height;
if (dst_x + @as(i64, @intCast(sprite.width)) > fb_w) {
const visible = fb_w - dst_x;
if (visible <= 0) return; // fully off the right
end_sx = @intCast(visible);
}
if (dst_y + @as(i64, @intCast(sprite.height)) > fb_h) {
const visible = fb_h - dst_y;
if (visible <= 0) return; // fully off the bottom
end_sy = @intCast(visible);
}
var sy = start_sy;
while (sy < end_sy) : (sy += 1) {
const dy: usize = @intCast(dst_y + @as(i64, @intCast(sy)));
var sx = start_sx;
while (sx < end_sx) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
const dx: usize = @intCast(dst_x + @as(i64, @intCast(sx)));
fb.pixels[dy * fb.width + dx] = px; // safe: the rectangle was already clipped
}
}
}
test "blitClipped writes exactly the same pixels as the naive blit at every position" {
const data = [_]Rgba{
.{ .r = 1, .g = 0, .b = 0 }, .{ .r = 2, .g = 0, .b = 0 }, .{ .r = 3, .g = 0, .b = 0 },
.{ .r = 4, .g = 0, .b = 0 }, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .{ .r = 6, .g = 0, .b = 0 },
.{ .r = 7, .g = 0, .b = 0 }, .{ .r = 8, .g = 0, .b = 0 }, .{ .r = 9, .g = 0, .b = 0 },
};
const sprite = Sprite{ .pixels = &data, .width = 3, .height = 3 };
const positions = [_][2]i64{ .{ 0, 0 }, .{ -1, -1 }, .{ 3, 2 }, .{ -2, 1 }, .{ 4, 4 } };
for (positions) |pos| {
var a: [5 * 5]Rgba = undefined;
var b: [5 * 5]Rgba = undefined;
var fa = Framebuffer{ .pixels = &a, .width = 5, .height = 5 };
var fb = Framebuffer{ .pixels = &b, .width = 5, .height = 5 };
@memset(fa.pixels, .{ .r = 0, .g = 0, .b = 0 });
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
blit(&fa, sprite, pos[0], pos[1]);
blitClipped(&fb, sprite, pos[0], pos[1]);
for (fa.pixels, fb.pixels) |pa, pb| {
try std.testing.expectEqual(pa.r, pb.r);
}
}
}
That equivalence test is the point of the whole section. An optimization you cannot prove equal to the simple version is not an optimization -- it is a new bug waiting for a corner case. So I keep the slow, obviously-correct blit around forever as an oracle, and pin the fast one against it at five positions including two that hang off the edges. If blitClipped ever disagrees, the test goes red and tells me exactly which position broke. This is the same measure-first, prove-it discipline from the profiling episode: make it correct, then make it fast, then prove the fast one is still correct.
The tile map has a bigger, structural win available too. Our drawTileMap loops over every cell in the world, even in a million-cell level where only a few hundred cells are on screen. The fix is culling: compute which rows and columns the camera can actually see, and iterate only those:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
};
pub const Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
pub const TileSet = struct { tiles: []const Sprite, tile_w: usize, tile_h: usize };
pub const TileMap = struct {
indices: []const u16,
cols: usize,
rows: usize,
pub const empty_tile: u16 = std.math.maxInt(u16);
pub fn at(self: TileMap, col: usize, row: usize) u16 {
return self.indices[row * self.cols + col];
}
};
fn drawTileMap(fb: *Framebuffer, map: TileMap, set: TileSet, cam_x: i64, cam_y: i64) void {
var row: usize = 0;
while (row < map.rows) : (row += 1) {
var col: usize = 0;
while (col < map.cols) : (col += 1) {
const idx = map.at(col, row);
if (idx == TileMap.empty_tile) continue;
blit(fb, set.tiles[idx], @as(i64, @intCast(col * set.tile_w)) - cam_x, @as(i64, @intCast(row * set.tile_h)) - cam_y);
}
}
}
fn clampCol(map: TileMap, c: i64) usize {
if (c < 0) return 0;
const uc: usize = @intCast(c);
return @min(uc, map.cols - 1);
}
fn clampRow(map: TileMap, r: i64) usize {
if (r < 0) return 0;
const ur: usize = @intCast(r);
return @min(ur, map.rows - 1);
}
fn drawTileMapCulled(fb: *Framebuffer, map: TileMap, set: TileSet, cam_x: i64, cam_y: i64) void {
const tw: i64 = @intCast(set.tile_w);
const th: i64 = @intCast(set.tile_h);
// only the cells whose pixels can land on screen
const first_col = clampCol(map, @divFloor(cam_x, tw));
const last_col = clampCol(map, @divFloor(cam_x + @as(i64, @intCast(fb.width)) - 1, tw));
const first_row = clampRow(map, @divFloor(cam_y, th));
const last_row = clampRow(map, @divFloor(cam_y + @as(i64, @intCast(fb.height)) - 1, th));
var row = first_row;
while (row <= last_row) : (row += 1) {
var col = first_col;
while (col <= last_col) : (col += 1) {
const idx = map.at(col, row);
if (idx == TileMap.empty_tile) continue;
blit(fb, set.tiles[idx], @as(i64, @intCast(col * set.tile_w)) - cam_x, @as(i64, @intCast(row * set.tile_h)) - cam_y);
}
}
}
test "culled tile-map rendering matches the brute-force version for a scrolled camera" {
const red = [_]Rgba{
.{ .r = 200, .g = 0, .b = 0 }, .{ .r = 200, .g = 0, .b = 0 },
.{ .r = 200, .g = 0, .b = 0 }, .{ .r = 200, .g = 0, .b = 0 },
};
const blue = [_]Rgba{
.{ .r = 0, .g = 0, .b = 200 }, .{ .r = 0, .g = 0, .b = 200 },
.{ .r = 0, .g = 0, .b = 200 }, .{ .r = 0, .g = 0, .b = 200 },
};
const tiles = [_]Sprite{
.{ .pixels = &red, .width = 2, .height = 2 },
.{ .pixels = &blue, .width = 2, .height = 2 },
};
const set = TileSet{ .tiles = &tiles, .tile_w = 2, .tile_h = 2 };
var cells: [6 * 6]u16 = undefined;
for (0..cells.len) |i| cells[i] = @intCast(i % 2);
const map = TileMap{ .indices = &cells, .cols = 6, .rows = 6 };
var a: [5 * 5]Rgba = undefined;
var b: [5 * 5]Rgba = undefined;
var fa = Framebuffer{ .pixels = &a, .width = 5, .height = 5 };
var fb = Framebuffer{ .pixels = &b, .width = 5, .height = 5 };
@memset(fa.pixels, .{ .r = 0, .g = 0, .b = 0 });
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
drawTileMap(&fa, map, set, 3, 3); // brute force
drawTileMapCulled(&fb, map, set, 3, 3); // culled
for (fa.pixels, fb.pixels) |pa, pb| {
try std.testing.expectEqual(pa.r, pb.r);
try std.testing.expectEqual(pa.b, pb.b);
}
}
Same trick, same payoff. The culled loop only ever visits the cells overlapping the visible rectangle, so on a huge map it does a constant amount of work regardless of world size -- the difference between a smooth scroller and an unplayable slideshow. And again I refuse to trust it on faith: the test renders the same scrolled scene both ways and asserts every pixel matches. The clamping helpers keep the window inside the map's bounds, and because blit still clips at the pixel level, a boundary tile that pokes only slightly onto the screen is handled correctly by both paths. Correct first, fast second, proven equal always.
This pair of ideas is the skeleton of essentially every 2D game and a fair chunk of 2D graphics generally. When you use SDL and call SDL_RenderCopy, you are blitting a texture (a sprite living in GPU memory) into a target -- same operation, hardware-accelerated. The lovingly hand-drawn worlds of classic 2D platformers, top-down RPGs and puzzle games are tile maps to a one; the level editor Tiled exists precisely to paint these grids of indices and export them, and its .tmx files are, at heart, exactly our indices array plus a reference to a tile set. Text rendering, which we turn to next, is also sprite blitting -- each glyph is a tiny sprite, and a line of text is a row of blits. Even a desktop's mouse cursor is historically a sprite composited over your screen. Learn this one stamp-with-transparency-and-a-camera pattern and you hold the spine of how flat, image-based graphics are drawn everywhere.
The blit is language-neutral; what changes is who remembers the bounds check. In C you write it by hand, every time, and the day you forget is the day you corrupt memory:
// C: stamp a sprite, skip transparent pixels, clip by hand -- forget a line and you crash
typedef struct { uint8_t r, g, b, a; } Rgba;
typedef struct { Rgba *pixels; int w, h; } Framebuffer;
typedef struct { const Rgba *pixels; int w, h; } Sprite;
void blit(Framebuffer *fb, Sprite s, int dx, int dy) {
for (int sy = 0; sy < s.h; sy++) {
for (int sx = 0; sx < s.w; sx++) {
Rgba px = s.pixels[sy * s.w + sx];
if (px.a == 0) continue;
int x = dx + sx, y = dy + sy;
if (x < 0 || y < 0 || x >= fb->w || y >= fb->h) continue; // YOU must remember this
fb->pixels[y * fb->w + x] = px;
}
}
}
Rust carries lengths on its slices and bounds-checks indexing for you, and the borrow checker keeps the const source and mutable destination cleanly apart:
// Rust: slices know their own length; indexing is bounds-checked by the language
struct Rgba { r: u8, g: u8, b: u8, a: u8 }
struct Framebuffer<'a> { pixels: &'a mut [Rgba], w: i64, h: i64 }
struct Sprite<'a> { pixels: &'a [Rgba], w: i64, h: i64 }
fn blit(fb: &mut Framebuffer, s: &Sprite, dx: i64, dy: i64) {
for sy in 0..s.h {
for sx in 0..s.w {
let px = &s.pixels[(sy * s.w + sx) as usize];
if px.a == 0 { continue; }
let (x, y) = (dx + sx, dy + sy);
if x < 0 || y < 0 || x >= fb.w || y >= fb.h { continue; }
fb.pixels[(y * fb.w + x) as usize] = Rgba { r: px.r, g: px.g, b: px.b, a: px.a };
}
}
}
Go makes slices reference types and bounds-checks every index at runtime, so the loop reads almost like the Zig one:
// Go: slices are reference types; the runtime bounds-checks every index for you
type Rgba struct{ R, G, B, A uint8 }
type Framebuffer struct { Pixels []Rgba; W, H int }
type Sprite struct { Pixels []Rgba; W, H int }
func Blit(fb *Framebuffer, s Sprite, dx, dy int) {
for sy := 0; sy < s.H; sy++ {
for sx := 0; sx < s.W; sx++ {
px := s.Pixels[sy*s.W+sx]
if px.A == 0 {
continue
}
x, y := dx+sx, dy+sy
if x < 0 || y < 0 || x >= fb.W || y >= fb.H {
continue
}
fb.Pixels[y*fb.W+x] = px
}
}
}
The through-line is the one we keep meeting. Everyone runs the identical loop; they differ in how much the language protects you and what that protection costs. C is fastest and least forgiving; Go pays a runtime bounds check on every access; Rust and Zig both check in safe builds but let you drop into a proven-safe unchecked write (our blitClipped pre-clips exactly so the inner store needs no guard). Having said that, Zig sits where it always sits -- C's flat, allocation-free layout and exact costs, but with []const sources the compiler enforces, checked indexing in debug, and the freedom to remove the check only where you have proved it redundant.
A flip flag. Add a flip_h: bool parameter to blit so a sprite can be mirrored left-to-right (handy for a character that walks both ways from one drawing). When flipping, read sprite column sprite.width - 1 - sx instead of sx. Write a test that blits a small asymmetric sprite both ways and asserts the mirrored result is the column-reversed original.
Animated tiles. Extend TileSet (or add a wrapper) so a tile index can map to a short sequence of sprites cycled by a frame counter -- think flowing water or a flickering torch. Give your draw function a frame: u64 parameter, pick frames[frame % frames.len] for animated tiles, and test that the same cell shows different tiles at different frame numbers.
A multi-layer map. Real levels stack layers: a background, the solid world, and a foreground drawn over the player. Represent a level as a small array of TileMaps sharing one TileSet, and draw them back-to-front. Write a test proving that a foreground tile correctly overwrites a background tile in the same cell (draw order matters), and that an empty_tile in the foreground lets the background show through.
a == 0) lets non-rectangular shapes sit on any background -- distinct from true alpha blending, which mixes partial transparency and is a separate topic still ahead of us;blit leans on setPixel's bounds check; the fast blitClipped computes the overlap rectangle once so its inner loop needs no per-pixel guard;empty_tile) for holes and a camera subtraction for scrolling;[]const Rgba forbids drawing into a sprite, checked indexing catches a bad tile index where it happens, and you remove a bounds check only where you have proved it safe;We can now stamp finished pictures anywhere on a scrolling world, cheaply and safely. But every sprite so far has been pixels we conjured by hand in a test array -- fine for a red square, useless for the single most common thing any program needs to put on a screen: words. And it turns out a letter is nothing more than a very small sprite with a very particular shape, stored in a compact table. Teaching our renderer to stamp those letter-shapes -- to finally draw text on the canvas -- is where we head next.
De groeten, and see you in the next one! ;-)