Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering

Project F: ECS Game Engine Core (4/4)
What will I learn
- You will learn how to define a Sprite component with character, foreground color, and layer ordering;
- You will learn terminal rendering with ANSI escape codes for color and cursor control;
- You will learn double buffering: drawing to a back buffer then swapping to the screen in one write;
- You will learn the render system: iterating entities with Sprite + Position components and drawing them to the buffer;
- You will learn how to build a basic game loop: read input, run systems, render, sleep;
- You will learn how to create a bouncing entities demo on a terminal canvas;
- You will learn frame rate control with nanosecond timing;
- You will learn a project retrospective: what a production ECS adds beyond what we built.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering (this post)
Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
Last episode we built systems and queries -- the logic layer that transforms component data every frame. We have gravity pulling entities down, movement updating positions, collision detection finding AABB overlaps, and damage reducing health when things collide. The engine runs, entities move, physics works... but we can't see any of it. Everything happens in memory and we print debug text at the end of each frame. Today we fix that. We're building the rendering layer -- the part that takes our game state and draws it to a terminal screen with colored characters, proper layering, and smooth animation.
This is the final episode of the ECS mini-project. By the end of it we'll have a complete (if simple) game engine: entities, components, systems, queries, physics, collision, AND visible output. A bouncing-entities demo running in your terminal at a stable frame rate. Here we go!
The Sprite component
Every entity that should be visible on screen needs a Sprite component. A sprite in a terminal context is just a character, a color, and a layer number for draw ordering:
const std = @import("std");
const Sprite = struct {
char: u8,
fg_color: u8, // ANSI color code (0-7 normal, 8-15 bright)
bg_color: u8, // 0 = transparent (don't draw background)
layer: i8, // higher layers draw on top
};
const Position = struct {
x: f32,
y: f32,
};
const Velocity = struct {
dx: f32,
dy: f32,
};
The fg_color field uses standard ANSI color indices: 0 = black, 1 = red, 2 = green, 3 = yellow, 4 = blue, 5 = magenta, 6 = cyan, 7 = white. Add 8 for bright variants (so 9 = bright red, 10 = bright green, etc.). The bg_color works the same way but we use 0 to mean "no background" -- the terminal's default background shows through. And layer controls draw ordering: entities on layer 0 get drawn first, entities on layer 1 draw on top of them. A wall might be layer 0, the player layer 1, particles and effects layer 2.
Why separate foreground and background colors? Because terminal characters are inherently two-layered -- each cell has a character with a foreground color drawn on top of a background color. If we only had one color we couldn't do things like a red @ player on a green grass tile. Having said that, most entities in our demo will use bg_color = 0 (transparent) and only set the foreground. Background colors are there for when you want solid blocks of color -- walls, floors, UI elements.
ANSI escape codes for terminal rendering
To actually draw colored characters at specific positions we need ANSI escape codes. These are special byte sequences that terminals interpret as commands rather than printable text. The key codes we need:
const Ansi = struct {
// Move cursor to row, col (1-indexed!)
fn moveCursor(writer: anytype, row: u16, col: u16) !void {
try writer.print("\x1b[{d};{d}H", .{ row, col });
}
// Set foreground color (0-7 normal, 8-15 bright)
fn setFg(writer: anytype, color: u8) !void {
if (color < 8) {
try writer.print("\x1b[{d}m", .{30 + @as(u16, color)});
} else {
try writer.print("\x1b[{d}m", .{82 + @as(u16, color)});
// bright: 90-97
}
}
// Set background color
fn setBg(writer: anytype, color: u8) !void {
if (color == 0) return; // transparent = skip
if (color < 8) {
try writer.print("\x1b[{d}m", .{40 + @as(u16, color)});
} else {
try writer.print("\x1b[{d}m", .{92 + @as(u16, color)});
}
}
// Reset all attributes
fn reset(writer: anytype) !void {
try writer.writeAll("\x1b[0m");
}
// Clear entire screen
fn clearScreen(writer: anytype) !void {
try writer.writeAll("\x1b[2J");
}
// Hide cursor
fn hideCursor(writer: anytype) !void {
try writer.writeAll("\x1b[?25l");
}
// Show cursor
fn showCursor(writer: anytype) !void {
try writer.writeAll("\x1b[?25h");
}
};
The \x1b[ prefix is the CSI (Control Sequence Introducer) -- every ANSI escape sequence starts with it. \x1b is the escape character (ASCII 27, sometimes written as \033 in octal or \e in some languages). After the CSI you put parameters separated by semicolons and end with a command letter: H for cursor position, m for graphics mode (colors), J for screen clear.
One important gotcha: cursor positions are 1-indexed, not 0-indexed. Row 1, column 1 is the top-left corner. So if your game buffer uses 0-indexed coordinates (which it should), you add 1 when writing the escape sequence. Forgetting this is a classic off-by-one that makes everything render one cell to the right and one cell down.
We used escape codes briefly back in episode 24 for colored log output, but here we're using the full set -- cursor positioning, screen clearing, cursor hiding. The cursor needs to be hidden during rendering because a visible blinking cursor jumping around the screen every frame looks terrible.
The screen buffer: double buffering
Directly writing each sprite to the terminal as we iterate entities would cause flicker -- the screen updates mid-draw and you see half-rendered frames. The solution is double buffering: maintain two buffers in memory (front and back), draw everything to the back buffer, then write the entire back buffer to the terminal in one go. The swap from back to front is effectively instantaneous from the user's perspective.
const Cell = struct {
char: u8,
fg_color: u8,
bg_color: u8,
};
const EMPTY_CELL = Cell{ .char = ' ', .fg_color = 7, .bg_color = 0 };
const ScreenBuffer = struct {
width: u16,
height: u16,
front: []Cell,
back: []Cell,
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator, width: u16, height: u16) !ScreenBuffer {
const size = @as(usize, width) * @as(usize, height);
const front = try allocator.alloc(Cell, size);
const back = try allocator.alloc(Cell, size);
@memset(front, EMPTY_CELL);
@memset(back, EMPTY_CELL);
return .{
.width = width,
.height = height,
.front = front,
.back = back,
.allocator = allocator,
};
}
fn deinit(self: *ScreenBuffer) void {
self.allocator.free(self.front);
self.allocator.free(self.back);
}
fn clear(self: *ScreenBuffer) void {
@memset(self.back, EMPTY_CELL);
}
fn setCell(self: *ScreenBuffer, x: u16, y: u16, cell: Cell) void {
if (x >= self.width or y >= self.height) return;
const idx = @as(usize, y) * @as(usize, self.width) + @as(usize, x);
self.back[idx] = cell;
}
fn getCell(self: *const ScreenBuffer, x: u16, y: u16) Cell {
if (x >= self.width or y >= self.height) return EMPTY_CELL;
const idx = @as(usize, y) * @as(usize, self.width) + @as(usize, x);
return self.back[idx];
}
fn swap(self: *ScreenBuffer) void {
const tmp = self.front;
self.front = self.back;
self.back = tmp;
}
};
Each Cell stores a character and its colors. The buffer is a flat array indexed as y * width + x. The setCell function silently discards writes that fall outside the buffer bounds -- this is intentional. Entities can move off-screen without causing crashes. They just don't render until they come back into view.
The swap function is a pointer swap, not a copy. After drawing to the back buffer and flushing it to the terminal, we swap the pointers so the old front becomes the new back (which we'll clear next frame). This avoids allocating new buffers every frame.
Why not just use one buffer? Because during the rendering phase we need the previous frame's content to know what changed (for differential updates), and we need a clean slate to draw the new frame into. With two buffers we can compare front vs back to only redraw cells that actually changed -- a huge optimization for terminals where writing is expensive relative to computation.
Flushing the buffer to the terminal
The flush function writes only the cells that differ between front and back buffer. This is where the actual terminal output happens:
fn flush(self: *ScreenBuffer, writer: anytype) !void {
var last_fg: u8 = 255; // impossible value = force first color set
var last_bg: u8 = 255;
var y: u16 = 0;
while (y < self.height) : (y += 1) {
var x: u16 = 0;
while (x < self.width) : (x += 1) {
const idx = @as(usize, y) * @as(usize, self.width) + @as(usize, x);
const back_cell = self.back[idx];
const front_cell = self.front[idx];
// Only redraw cells that changed
if (back_cell.char == front_cell.char and
back_cell.fg_color == front_cell.fg_color and
back_cell.bg_color == front_cell.bg_color)
{
continue;
}
// Move cursor to this position (1-indexed)
try Ansi.moveCursor(writer, y + 1, x + 1);
// Set colors only if they changed from last write
if (back_cell.fg_color != last_fg) {
try Ansi.setFg(writer, back_cell.fg_color);
last_fg = back_cell.fg_color;
}
if (back_cell.bg_color != last_bg and back_cell.bg_color != 0) {
try Ansi.setBg(writer, back_cell.bg_color);
last_bg = back_cell.bg_color;
} else if (back_cell.bg_color == 0 and last_bg != 0) {
try Ansi.reset(writer);
last_fg = 255; // force re-set after reset
last_bg = 0;
}
try writer.writeByte(back_cell.char);
}
}
self.swap();
}
The differential update is critical for performance. A typical terminal is 80x24 = 1,920 cells. Writing all of them every frame at 30 FPS means 57,600 escape sequences per second being sent over the terminal's output stream. But in a game where only a few entities move each frame, maybe 20-50 cells actually change. The diff cuts our output by 95%+ and prevents the terminal from becoming the bottleneck.
The last_fg/last_bg tracking is another optimization. ANSI color codes persist until explicitly changed or reset, so if consecutive writes use the same color we skip the color-change escape sequences entirely. Writing \x1b[31m (red) once and then five characters is cheaper than writing \x1b[31m before each of those five characters.
The render system
Now we build the actual render system -- a function that queries all entities with Position + Sprite components, sorts them by layer, and draws them to the screen buffer:
const RenderSystem = struct {
buffer: *ScreenBuffer,
// Sort buffer for layer ordering
sort_buf: [512]SortEntry = undefined,
const SortEntry = struct {
x: u16,
y: u16,
sprite: Sprite,
};
fn run(self: *RenderSystem, world: *World, _: f32) void {
self.buffer.clear();
// Collect all renderable entities
var count: usize = 0;
var it = world.query2(Position, Sprite);
while (it.next()) |result| {
// Convert float position to integer cell coordinates
const ix = @as(i32, @intFromFloat(@round(result.a.x)));
const iy = @as(i32, @intFromFloat(@round(result.a.y)));
// Bounds check -- skip entities outside the screen
if (ix < 0 or iy < 0) continue;
const ux = @as(u16, @intCast(@min(ix, std.math.maxInt(u16))));
const uy = @as(u16, @intCast(@min(iy, std.math.maxInt(u16))));
if (ux >= self.buffer.width or uy >= self.buffer.height) continue;
if (count < 512) {
self.sort_buf[count] = .{
.x = ux,
.y = uy,
.sprite = result.b.*,
};
count += 1;
}
}
// Sort by layer so higher layers draw on top
std.mem.sortUnstable(SortEntry, self.sort_buf[0..count], {}, struct {
fn lessThan(_: void, a: SortEntry, b: SortEntry) bool {
return a.sprite.layer < b.sprite.layer;
}
}.lessThan);
// Draw sorted sprites to back buffer
for (self.sort_buf[0..count]) |entry| {
self.buffer.setCell(entry.x, entry.y, .{
.char = entry.sprite.char,
.fg_color = entry.sprite.fg_color,
.bg_color = entry.sprite.bg_color,
});
}
}
};
The render system collects all entities that have both Position and Sprite into a stack-allocated sort buffer (same pattern as the collision system from last episode), sorts them by layer, then draws them in order. Lower layers draw first, higher layers overwrite them. This means a player on layer 1 will always appear on top of a floor tile on layer 0, even if they occupy the same cell.
The float-to-integer conversion uses @round rather than truncation. This gives smoother visual movement -- an entity at position (4.7, 3.2) rounds to cell (5, 3) rather than (4, 3). Without rounding, entities would appear to "stick" to integer positions until they cross the threshold, making movement look jerky.
Notice the system takes *RenderSystem as self -- it's a method on a struct, not a bare function. This is how we give the render system access to the screen buffer without using globals. We can't directly use it with world.addSystem (which expects SystemFn), but we'll integrate it differently in the game loop.
The game loop
Every game, from Pong to a modern AAA title, runs the same fundamental loop: process input, update game state, render to screen, wait until next frame. The timing between frames determines the frame rate:
const GameLoop = struct {
world: *World,
render: RenderSystem,
timer: Timer,
target_ns: i64,
running: bool,
const Timer = struct {
last_time: i64,
fn init() Timer {
return .{ .last_time = std.time.nanoTimestamp() };
}
fn tick(self: *Timer) f32 {
const now = std.time.nanoTimestamp();
const elapsed = now - self.last_time;
self.last_time = now;
const dt: f32 = @floatFromInt(elapsed);
return dt / 1_000_000_000.0;
}
};
fn init(world: *World, buffer: *ScreenBuffer, target_fps: u32) GameLoop {
const ns_per_frame: i64 = @divFloor(1_000_000_000, @as(i64, target_fps));
return .{
.world = world,
.render = .{ .buffer = buffer },
.timer = Timer.init(),
.target_ns = ns_per_frame,
.running = true,
};
}
fn runFrame(self: *GameLoop) !f32 {
const frame_start = std.time.nanoTimestamp();
// 1. Update game systems (physics, collisions, etc.)
const dt = self.timer.tick();
self.world.runSystems(dt);
// 2. Render
self.render.run(self.world, dt);
// 3. Flush buffer to terminal
const stdout = std.io.getStdOut().writer();
try self.render.buffer.flush(stdout);
// 4. Frame timing -- sleep to maintain target FPS
const frame_end = std.time.nanoTimestamp();
const frame_elapsed = frame_end - frame_start;
const sleep_ns = self.target_ns - frame_elapsed;
if (sleep_ns > 0) {
std.time.sleep(@intCast(sleep_ns));
}
return dt;
}
};
The target_ns field controls the frame rate. At 30 FPS each frame should take 33.33 milliseconds. After running systems and rendering, we measure how much time has elapsed and sleep for the remainder. If the frame took longer than the target (dropped frame), we skip the sleep and run the next frame immediately.
This is the simplest frame timing approach -- fixed sleep. A more robust version would accumulate timing error and adjust future sleeps to compensate for OS scheduling jitter. But for a terminal demo, sleeping until the next frame boundary is plenty good enough. The Timer struct gives us dt in seconds (same as we used for the movement system in episode 57), so all physics remains frame-rate independent regardless of whether we hit our target or not.
Handling terminal input
A game needs input. For terminal games we read keyboard input from stdin in non-blocking mode. On POSIX systems this means switching the terminal to raw mode (no line buffering, no echo) and using poll or non-blocking reads:
const TerminalInput = struct {
original_termios: std.posix.termios,
fn init() !TerminalInput {
const stdin_fd = std.io.getStdIn().handle;
const original = try std.posix.tcgetattr(stdin_fd);
var raw = original;
// Disable canonical mode and echo
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
// Minimum 0 chars, timeout 0 -- non-blocking
raw.cc[@intFromEnum(std.posix.V.MIN)] = 0;
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
try std.posix.tcsetattr(stdin_fd, .NOW, raw);
return .{ .original_termios = original };
}
fn deinit(self: *TerminalInput) void {
const stdin_fd = std.io.getStdIn().handle;
std.posix.tcsetattr(stdin_fd, .NOW, self.original_termios) catch {};
}
fn readKey(self: *const TerminalInput) ?u8 {
_ = self;
var buf: [1]u8 = undefined;
const stdin = std.io.getStdIn();
const n = stdin.read(&buf) catch return null;
if (n == 0) return null;
return buf[0];
}
};
The tcgetattr/tcsetattr dance switches stdin into raw mode. ICANON = false disables line buffering (normally the terminal waits for Enter before delivering input). ECHO = false prevents typed characters from appearing on screen (we're rendering our own content). VMIN = 0, VTIME = 0 makes reads non-blocking -- read returns immediately with 0 bytes if nothing is available.
The deinit function restores the original terminal settings. This is CRITICAL -- if you crash without restoring, your terminal stays in raw mode and becomes unusable (no echo, no line editing). In a production program you'd also install a signal handler for SIGINT/SIGTERM to ensure cleanup happens on crashes. We covered signal handling patterns in episode 50 -- same defer-based cleanup pattern applies here.
The bouncing entities demo
Time to put everything together. We'll spawn several entities with different sprites and velocities, let them bounce off the screen edges, and render the whole thing at 30 FPS:
const BounceSystem = struct {
width: f32,
height: f32,
};
var bounce_bounds: BounceSystem = .{ .width = 80.0, .height = 24.0 };
fn bounceSystem(world: *World, _: f32) void {
var it = world.query2(Position, Velocity);
while (it.next()) |result| {
const pos = result.a;
const vel = result.b;
// Bounce off left/right walls
if (pos.x <= 0.0 and vel.dx < 0.0) {
vel.dx = -vel.dx;
pos.x = 0.0;
} else if (pos.x >= bounce_bounds.width - 1.0 and vel.dx > 0.0) {
vel.dx = -vel.dx;
pos.x = bounce_bounds.width - 1.0;
}
// Bounce off top/bottom walls
if (pos.y <= 0.0 and vel.dy < 0.0) {
vel.dy = -vel.dy;
pos.y = 0.0;
} else if (pos.y >= bounce_bounds.height - 1.0 and vel.dy > 0.0) {
vel.dy = -vel.dy;
pos.y = bounce_bounds.height - 1.0;
}
}
}
fn movementSystem(world: *World, dt: f32) void {
var it = world.query2(Position, Velocity);
while (it.next()) |result| {
result.a.x += result.b.dx * dt;
result.a.y += result.b.dy * dt;
}
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("WARNING: memory leak detected\n", .{});
}
const allocator = gpa.allocator();
// Terminal setup
var input = try TerminalInput.init();
defer input.deinit();
const stdout = std.io.getStdOut().writer();
try Ansi.hideCursor(stdout);
defer Ansi.showCursor(stdout) catch {};
try Ansi.clearScreen(stdout);
// Create screen buffer
const width: u16 = 80;
const height: u16 = 24;
var buffer = try ScreenBuffer.init(allocator, width, height);
defer buffer.deinit();
// Create ECS world
var world = World.init(allocator);
defer world.deinit();
try world.registerComponent(Position);
try world.registerComponent(Velocity);
try world.registerComponent(Sprite);
// Register systems
try world.addSystem("bounce", &bounceSystem, 100);
try world.addSystem("movement", &movementSystem, 200);
// Spawn bouncing entities
const entities_data = [_]struct { char: u8, fg: u8, x: f32, y: f32, dx: f32, dy: f32 }{
.{ .char = '@', .fg = 10, .x = 5.0, .y = 5.0, .dx = 12.0, .dy = 8.0 },
.{ .char = '*', .fg = 11, .x = 40.0, .y = 12.0, .dx = -15.0, .dy = 6.0 },
.{ .char = '#', .fg = 9, .x = 60.0, .y = 3.0, .dx = 8.0, .dy = -10.0 },
.{ .char = 'O', .fg = 14, .x = 20.0, .y = 18.0, .dx = -10.0, .dy = -7.0 },
.{ .char = '+', .fg = 13, .x = 70.0, .y = 20.0, .dx = 6.0, .dy = 9.0 },
.{ .char = '~', .fg = 12, .x = 35.0, .y = 8.0, .dx = -8.0, .dy = 11.0 },
};
for (entities_data) |data| {
const e = try world.spawn();
try world.getStorage(Position).set(e.index, .{ .x = data.x, .y = data.y });
try world.getStorage(Velocity).set(e.index, .{ .dx = data.dx, .dy = data.dy });
try world.getStorage(Sprite).set(e.index, .{
.char = data.char,
.fg_color = data.fg,
.bg_color = 0,
.layer = 0,
});
}
// Game loop
var game_loop = GameLoop.init(&world, &buffer, 30);
while (game_loop.running) {
_ = try game_loop.runFrame();
// Check for quit input
if (input.readKey()) |key| {
if (key == 'q' or key == 27) { // 'q' or Escape
game_loop.running = false;
}
}
}
// Cleanup: clear screen and show cursor
try Ansi.clearScreen(stdout);
try Ansi.moveCursor(stdout, 1, 1);
try Ansi.showCursor(stdout);
try Ansi.reset(stdout);
}
Six colored entities bounce around an 80x24 terminal screen. The @ is bright green, the * is bright yellow, the # is bright red -- each one a different character with a different color, all moving at different speeds and angles. When they hit a wall they reverse direction on that axis and keep going. Press 'q' or Escape to quit.
The important architectural point: the main function does NOT contain rendering logic. It sets up the ECS world, registers systems, spawns entities, and runs the game loop. All behavior comes from the systems -- movement updates positions, bounce reverses velocities at walls, and the render system draws sprites to the buffer. Adding new behavior means adding a new system, not modifying the main loop. Want particles that fade over time? Add a FadeSystem that reduces sprite alpha each frame. Want enemies that chase the player? Add a ChaseSystem that adjusts velocity toward the player's position. The architecture is open for extension without touching existing code.
Frame rate control and timing
Let's look more carefully at what happens when the frame takes too long:
const FrameStats = struct {
frame_count: u64,
total_time_ns: i64,
worst_frame_ns: i64,
dropped_frames: u64,
fn init() FrameStats {
return .{
.frame_count = 0,
.total_time_ns = 0,
.worst_frame_ns = 0,
.dropped_frames = 0,
};
}
fn recordFrame(self: *FrameStats, elapsed_ns: i64, target_ns: i64) void {
self.frame_count += 1;
self.total_time_ns += elapsed_ns;
if (elapsed_ns > self.worst_frame_ns) {
self.worst_frame_ns = elapsed_ns;
}
if (elapsed_ns > target_ns) {
self.dropped_frames += 1;
}
}
fn avgFrameMs(self: *const FrameStats) f64 {
if (self.frame_count == 0) return 0.0;
const avg_ns: f64 = @floatFromInt(self.total_time_ns);
const count: f64 = @floatFromInt(self.frame_count);
return (avg_ns / count) / 1_000_000.0;
}
fn worstFrameMs(self: *const FrameStats) f64 {
const ns: f64 = @floatFromInt(self.worst_frame_ns);
return ns / 1_000_000.0;
}
fn dropRate(self: *const FrameStats) f64 {
if (self.frame_count == 0) return 0.0;
const dropped: f64 = @floatFromInt(self.dropped_frames);
const total: f64 = @floatFromInt(self.frame_count);
return (dropped / total) * 100.0;
}
};
Frame stats help you understand whether your game is hitting its target rate. In a terminal game with 6 bouncing characters the answer is "obviously yes" -- the work per frame is negligible compared to 33ms. But when you scale up to hundreds of entities with complex systems, knowing your average frame time, worst-case frame, and drop rate tells you where performance problems are hiding.
The worst-case frame is often the most important metric. A game that runs at smooth 30 FPS but occasionally stutters to 200ms (a single dropped frame) feels worse than one that runs at a steady 25 FPS. Human perception is more sensitive to inconsistency than to absolute rate. In a production engine you'd cap the maximum dt passed to systems (e.g. clamp to 0.1 seconds) to prevent physics explosions when a frame takes too long -- entities shouldn't teleport through walls because of one bad frame.
Drawing borders and static elements
To make the demo look more polished, let's add a border around the screen and a status bar. These are static elements that don't need ECS entities -- they're just written directly to the buffer before entities render:
fn drawBorder(buffer: *ScreenBuffer) void {
const w = buffer.width;
const h = buffer.height;
// Top and bottom border
var x: u16 = 0;
while (x < w) : (x += 1) {
buffer.setCell(x, 0, .{ .char = '-', .fg_color = 7, .bg_color = 0 });
buffer.setCell(x, h - 2, .{ .char = '-', .fg_color = 7, .bg_color = 0 });
}
// Left and right border
var y: u16 = 0;
while (y < h - 1) : (y += 1) {
buffer.setCell(0, y, .{ .char = '|', .fg_color = 7, .bg_color = 0 });
buffer.setCell(w - 1, y, .{ .char = '|', .fg_color = 7, .bg_color = 0 });
}
// Corners
buffer.setCell(0, 0, .{ .char = '+', .fg_color = 7, .bg_color = 0 });
buffer.setCell(w - 1, 0, .{ .char = '+', .fg_color = 7, .bg_color = 0 });
buffer.setCell(0, h - 2, .{ .char = '+', .fg_color = 7, .bg_color = 0 });
buffer.setCell(w - 1, h - 2, .{ .char = '+', .fg_color = 7, .bg_color = 0 });
}
fn drawStatusBar(buffer: *ScreenBuffer, frame: u64, entity_count: usize) void {
const h = buffer.height;
const status = "ECS Demo | Press 'q' to quit";
for (status, 0..) |char, i| {
if (i + 2 < buffer.width) {
buffer.setCell(@intCast(i + 2), h - 1, .{
.char = char,
.fg_color = 6, // cyan
.bg_color = 0,
});
}
}
// Frame counter on right side
var num_buf: [32]u8 = undefined;
const frame_str = std.fmt.bufPrint(&num_buf, "F:{d} E:{d}", .{ frame, entity_count }) catch return;
const start_x = buffer.width - @as(u16, @intCast(frame_str.len)) - 2;
for (frame_str, 0..) |char, i| {
buffer.setCell(start_x + @as(u16, @intCast(i)), h - 1, .{
.char = char,
.fg_color = 3, // yellow
.bg_color = 0,
});
}
}
The border and status bar draw before entities, so entities on layer 0+ will render on top if they overlap the border area. Adjust the bounce bounds to keep entities inside the border (1 to width-2, 1 to height-3) and the border stays intact. This is a common pattern in terminal games: draw the UI layer first, then game entities on top.
Project retrospective: what a production ECS adds
We've built a functional ECS from scratch across four episodes: entity IDs with generational indices (ep 55), type-erased sparse set component storage (ep 56), a system registry with priority scheduling and query iterators (ep 57), and now a double-buffered terminal renderer with frame timing. It works, it's testable, and the architecture is sound. But a production ECS adds quite some more on top of this foundation.
Events and messaging. Our collision system writes to a global buffer that the damage system reads. A production ECS has a proper event queue: systems can emit events (CollisionEvent, DamageEvent, DeathEvent) and other systems can subscribe to them. This decouples the producer from the consumer -- the collision system doesn't need to know that a damage system exists.
Resources (singletons). We pass the screen buffer as a struct field on the render system. In a production ECS you'd register it as a "resource" on the World -- a single instance of a type that any system can access. The input state, the audio mixer, the asset manager, configuration data -- all resources, all accessible through the World without special plumbing.
Parallel system execution. Our systems run sequentially. A production ECS analyzes which components each system reads and writes, builds a dependency graph, and runs non-conflicting systems in parallel. If the gravity system only writes Velocity and the render system only reads Position + Sprite, they can run concurrently on different threads. Zig's lack of hidden allocations and pointer aliasing guarantees makes this particularly well-suited for parallel ECS designs.
Archetypes. Our sparse sets store one component type each, and queries check each entity against multiple sparse sets. Archetype-based storage groups entities by their component combination -- all entities with exactly {Position, Velocity, Sprite} live in one contiguous table. Queries that match an archetype iterate that entire table with zero per-entity branching. This is what makes ECS frameworks like Bevy and flecs handle millions of entities at 60 FPS.
World serialization. Save games, network replication, hot reloading -- all require serializing the entire World state. With our current design you'd have to write custom serialization per component type. Production frameworks can serialize generically because they track component metadata at runtime.
None of these are necessary for understanding ECS. What matters is the core pattern: entities are IDs, components are data, systems are logic, and composition replaces inheritance. Everything else is optimization and tooling built on top of that foundation. If you understand what we've built across these four episodes, you understand ECS. The rest is engineering, not architecture ;-)
Wat we geleerd hebben
- The Sprite component stores a character, foreground color, background color, and layer for draw ordering
- ANSI escape codes control cursor position (
\x1b[row;colH), colors (\x1b[3Xmfor fg,\x1b[4Xmfor bg), and screen state (clear, cursor show/hide) - Double buffering means drawing to a back buffer then swapping -- this prevents flicker and enables differential updates where only changed cells get written to the terminal
- The render system collects entities with Position + Sprite, sorts by layer, and draws to the back buffer; the flush function writes only changed cells to stdout
- The game loop repeats: process input, run systems, render, sleep until next frame; frame timing uses nanosecond timestamps to maintain a stable target FPS
- The bounce demo shows ECS composability: 6 entities with 3 components each, 2 systems (bounce + movement), rendered at 30 FPS with zero entity-specific code in the main loop
- Frame statistics track average time, worst frame, and drop rate to identify performance problems before they become visible stutters
- Production ECS frameworks extend this foundation with events, resources, parallel scheduling, archetype storage, and world serialization -- but the core architecture is exactly what we built
This wraps up Project F -- four episodes from architecture to visible output. The ECS pattern shows up everywhere in game development, simulation, and even non-game contexts like UI frameworks and data processing pipelines. The concepts from these episodes (sparse sets, type erasure, system scheduling, differential rendering) transfer directly to whatever Zig project you build next.
Thanks for reading!
Congratulations @scipio! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP