Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
Learn Zig Series (#11) - Mini Project: Building a Step Sequencer

What will I learn
- You will learn how to combine structs, enums, arrays, error handling, file I/O, and modules into a real program;
- building a step sequencer -- a grid of notes and timing that plays a pattern in a loop;
- modeling musical patterns with fixed-size arrays and enums for note types;
- terminal rendering with ANSI escape codes for a live-updating display;
- save and load functionality using the file I/O patterns from episode 10;
- raw terminal input for immediate keypress handling;
- architecture patterns: separating engine, display, storage, and coordinator into distinct modules;
- how every concept from episodes 1-10 connects when you build something real.
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
- Intermediate
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 (this post)
Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
Welcome back! In episode #10 we learned how to structure a multi-file Zig project with zig init, split code into modules with @import, read and write files with std.fs, handle command-line arguments from std.process.args(), and even link C libraries directly from build.zig. We went from single-file experiments to a proper multi-module program with file I/O. That was the last piece of the foundation.
Ten episodes of focused learning. Now we build something real.
A step sequencer is a classic piece of music tech -- a grid where each column is a beat and each row is a sound. You toggle cells on and off, and the sequencer sweeps through columns left to right, triggering whatever's active on each step. Think of those grid-based drum machines from the 80s, or the sequencer views in modern DAWs like Ableton. It's a perfect mini project because it exercises almost everything we've covered: structs for the data model, enums for note types, arrays for the grid, file I/O for save/load, modules for separation of concerns, error handling for everything that touches the outside world, and even a bit of comptime for default values.
No audio output in this version (that would require linking a C audio library, which is a project in itself). Instead, we'll build the engine, the terminal display, and the pattern editor. The sequencer "plays" by printing which notes fire on each step. The architecture is what matters here -- once the engine works correctly, swapping the print-based output for real audio is just replacing one module.
Here we go!
Solutions to Episode 10 Exercises
Before we start building, here are the solutions to last episode's exercises. If you typed these out and compiled them (and I genuinely hope you did!), compare your approaches:
Exercise 1 -- two-file project:
// src/math_utils.zig
pub fn add(a: i64, b: i64) i64 {
return a + b;
}
pub fn multiply(a: i64, b: i64) i64 {
return a * b;
}
pub fn power(base: i64, exp: u32) i64 {
var result: i64 = 1;
for (0..exp) |_| {
result *= base;
}
return result;
}
// not pub -- invisible from main.zig
fn internalHelper() void {}
// src/main.zig
const std = @import("std");
const math = @import("math_utils.zig");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("add(3, 4) = {d}\n", .{math.add(3, 4)});
try stdout.print("multiply(5, 6) = {d}\n", .{math.multiply(5, 6)});
try stdout.print("power(2, 10) = {d}\n", .{math.power(2, 10)});
// math.internalHelper(); // compile error: not pub
}
The pub keyword controls the API boundary. Anything without pub stays private to that file -- you get a compile error, not a runtime error.
Exercise 2 -- file stats:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// Write 10 lines
const file = try std.fs.cwd().createFile("stats_test.txt", .{});
defer file.close();
const writer = file.writer();
for (0..10) |i| {
try writer.print("Line {d}: some data here\n", .{i + 1});
}
// Read back and count
const read_file = try std.fs.cwd().openFile("stats_test.txt", .{});
defer read_file.close();
var buf: [4096]u8 = undefined;
const n = try read_file.readAll(&buf);
const content = buf[0..n];
var lines: u32 = 0;
for (content) |byte| {
if (byte == '\n') lines += 1;
}
const chars: u32 = @intCast(n);
const avg = if (lines > 0) chars / lines else 0;
try stdout.print("Lines: {d}, Chars: {d}, Avg: {d}\n", .{ lines, chars, avg });
}
Counting '\n' gives the line count. readAll works for small files -- for larger ones use readFileAlloc with an allocator.
Exercise 3 -- CLI file reader:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var args = std.process.args();
_ = args.next(); // skip program name
const path = args.next() orelse {
try stdout.print("Usage: reader <filename>\n", .{});
return;
};
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
try stdout.print("Error: cannot open '{s}': {}\n", .{ path, err });
return;
};
defer file.close();
var buf: [4096]u8 = undefined;
const n = try file.readAll(&buf);
const content = buf[0..n];
var line_num: u32 = 1;
var iter = std.mem.splitScalar(u8, content, '\n');
while (iter.next()) |line| {
if (line.len > 0 or iter.peek() != null) {
try stdout.print("{d:>4}: {s}\n", .{ line_num, line });
line_num += 1;
}
}
}
Three distinct paths: no argument (usage), file not found (error), success (numbered lines). The catch on openFile handles the missing file without crashing.
Exercise 4 -- Tracker module with anytype writer:
// src/tracker.zig
const std = @import("std");
pub const Entry = struct {
ticker: []const u8,
value: f64,
};
pub const Tracker = struct {
entries: [16]Entry = undefined,
count: usize = 0,
pub fn addEntry(self: *Tracker, ticker: []const u8, value: f64) void {
if (self.count < 16) {
self.entries[self.count] = .{ .ticker = ticker, .value = value };
self.count += 1;
}
}
pub fn totalValue(self: *const Tracker) f64 {
var total: f64 = 0;
for (self.entries[0..self.count]) |e| total += e.value;
return total;
}
pub fn summary(self: *const Tracker, writer: anytype) !void {
const total = self.totalValue();
for (self.entries[0..self.count]) |e| {
const pct = if (total > 0) e.value / total * 100.0 else 0.0;
try writer.print("{s:>6}: {d:>10.2} ({d:.1}%)\n", .{ e.ticker, e.value, pct });
}
try writer.print("{'':->30}\nTotal: {d:>10.2}\n", .{total});
}
};
The anytype parameter is the key -- summary works with both std.io.getStdOut().writer() and file.writer(). Same function, any output target.
Exercise 5 -- file copy tool:
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var args = std.process.args();
_ = args.next();
const src = args.next() orelse {
try stdout.print("Usage: copy <source> <destination>\n", .{});
return;
};
const dst = args.next() orelse {
try stdout.print("Error: missing destination argument\n", .{});
return;
};
const content = std.fs.cwd().readFileAlloc(allocator, src, 10 * 1024 * 1024) catch |err| {
try stdout.print("Error reading '{s}': {}\n", .{ src, err });
return;
};
defer allocator.free(content);
const out_file = std.fs.cwd().createFile(dst, .{}) catch |err| {
try stdout.print("Error creating '{s}': {}\n", .{ dst, err });
return;
};
defer out_file.close();
try out_file.writeAll(content);
try stdout.print("Copied {d} bytes: {s} -> {s}\n", .{ content.len, src, dst });
}
The defer allocator.free(content) is critical -- the GPA's deinit() will report leaks if you forget it. Three error paths, each with a clear message.
Exercise 6 -- RingBuffer from file:
const std = @import("std");
fn RingBuffer(comptime T: type, comptime cap: usize) type {
return struct {
buf: [cap]T = undefined,
head: usize = 0,
count: usize = 0,
fn push(self: *@This(), val: T) void {
self.buf[(self.head + self.count) % cap] = val;
if (self.count < cap) {
self.count += 1;
} else {
self.head = (self.head + 1) % cap;
}
}
fn average(self: *const @This()) f64 {
if (self.count == 0) return 0;
var sum: f64 = 0;
for (0..self.count) |i| {
sum += self.buf[(self.head + i) % cap];
}
return sum / @as(f64, @floatFromInt(self.count));
}
};
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const file = try std.fs.cwd().openFile("numbers.txt", .{});
defer file.close();
var buf: [4096]u8 = undefined;
const n = try file.readAll(&buf);
const content = buf[0..n];
var ring = RingBuffer(f64, 10){};
var iter = std.mem.splitScalar(u8, content, '\n');
while (iter.next()) |line| {
if (line.len == 0) continue;
const val = std.fmt.parseFloat(f64, line) catch continue;
ring.push(val);
}
try stdout.print("Average of last {d} values: {d:.2}\n", .{ ring.count, ring.average() });
}
The entire ring buffer lives on the stack -- [10]f64 is 80 bytes. No allocator, no heap, no cleanup needed. Push overwrites the oldest value when full. This is the comptime generic pattern from ep009 combined with file I/O from this episode.
Now -- let's build something real!
What We're Building
The program has four parts, each in its own file:
sequencer.zig-- the engine. Holds the pattern grid, the playback position, tempo, and methods to toggle notes, advance steps, and query what's active.display.zig-- the terminal renderer. Reads the sequencer state and draws the grid with ANSI colors. The playback cursor shows which step is currently active.storage.zig-- save and load. Writes the pattern to a human-readable text file and reads it back.main.zig-- the coordinator. Handles keyboard input, dispatches commands, owns the sequencer instance.
Same separation of concerns from ep010's multi-file example, but bigger. The engine doesn't know about the terminal. The display doesn't modify state. Storage doesn't know about the display. Main coordinates.
The Data Model
// src/sequencer.zig
const std = @import("std");
pub const NUM_TRACKS = 4;
pub const NUM_STEPS = 16;
pub const Sound = enum {
kick,
snare,
hihat,
clap,
pub fn label(self: Sound) []const u8 {
return switch (self) {
.kick => "KICK ",
.snare => "SNARE",
.hihat => "HIHAT",
.clap => "CLAP ",
};
}
pub fn symbol(self: Sound) u8 {
return switch (self) {
.kick => 'K',
.snare => 'S',
.hihat => 'H',
.clap => 'C',
};
}
};
pub const tracks: [NUM_TRACKS]Sound = .{ .kick, .snare, .hihat, .clap };
pub const Sequencer = struct {
grid: [NUM_TRACKS][NUM_STEPS]bool = [_][NUM_STEPS]bool{[_]bool{false} ** NUM_STEPS} ** NUM_TRACKS,
position: u32 = 0,
playing: bool = false,
bpm: u32 = 120,
pub fn toggle(self: *Sequencer, track: usize, step: usize) void {
if (track >= NUM_TRACKS or step >= NUM_STEPS) return;
self.grid[track][step] = !self.grid[track][step];
}
pub fn advance(self: *Sequencer) void {
self.position = (self.position + 1) % NUM_STEPS;
}
pub fn activeOnStep(self: Sequencer, step: u32) [NUM_TRACKS]bool {
var result: [NUM_TRACKS]bool = [_]bool{false} ** NUM_TRACKS;
for (0..NUM_TRACKS) |t| {
result[t] = self.grid[t][step];
}
return result;
}
pub fn currentActive(self: Sequencer) [NUM_TRACKS]bool {
return self.activeOnStep(self.position);
}
pub fn clear(self: *Sequencer) void {
for (0..NUM_TRACKS) |t| {
for (0..NUM_STEPS) |s| {
self.grid[t][s] = false;
}
}
self.position = 0;
}
pub fn loadPreset(self: *Sequencer) void {
self.clear();
// Classic four-on-the-floor kick
self.grid[0][0] = true;
self.grid[0][4] = true;
self.grid[0][8] = true;
self.grid[0][12] = true;
// Snare on 2 and 4
self.grid[1][4] = true;
self.grid[1][12] = true;
// Hihat on every other step
var i: usize = 0;
while (i < NUM_STEPS) : (i += 2) {
self.grid[2][i] = true;
}
// Clap accents
self.grid[3][4] = true;
self.grid[3][12] = true;
}
pub fn stepDelayMs(self: Sequencer) u64 {
// BPM to milliseconds per step (16th notes: 4 steps per beat)
return @as(u64, 60_000) / @as(u64, self.bpm) / 4;
}
};
Let me walk through the design decisions, because they map directly to what we've learned:
Enums for sound types -- same pattern from ep006. The Sound enum defines what each track represents. Methods on the enum (label, symbol) give us display strings without a separate lookup table. The switch is exhaustive -- the compiler guarantees we handle every variant. If we add a fifth sound later, every switch statement in the program will produce a compile error until we handle it. That's not a burden, it's a safety net.
A 2D boolean grid -- [NUM_TRACKS][NUM_STEPS]bool. Four tracks, sixteen steps. Each cell is either on or off. This lives entirely on the stack. No allocator needed. The [_]bool{false} ** NUM_STEPS initialization pattern fills every cell with false at compile time (remember ** for array repetition from ep005 and comptime defaults from ep009).
Pointer methods for mutation, value methods for read-only -- toggle takes *Sequencer because it modifies state. currentActive takes Sequencer by value because it only reads. Same convention from ep006 and ep008. The compiler enforces this -- if you accidentally try to modify self in a value method, it won't compile.
loadPreset bakes in a classic drum pattern. Four-on-the-floor kick (every 4th step), snare on the backbeat (steps 4 and 12), hi-hats on every even step, claps doubling the snare. If you've ever used a drum machine, you'll recognize this as the "demo" pattern ;-)
The Display
// src/display.zig
const std = @import("std");
const seq = @import("sequencer.zig");
const ESC = "\x1b[";
pub fn render(s: seq.Sequencer, cursor_track: usize, cursor_step: usize) void {
std.debug.print("{s}2J{s}H", .{ ESC, ESC }); // clear screen, cursor home
std.debug.print("=== STEP SEQUENCER === BPM: {d} {s}\n\n", .{
s.bpm,
if (s.playing) "[PLAYING]" else "[STOPPED]",
});
// Header row with step numbers
std.debug.print(" ", .{});
for (0..seq.NUM_STEPS) |step| {
if (step == s.position and s.playing) {
std.debug.print("{s}43m{d:>2} {s}0m", .{ ESC, step + 1, ESC }); // yellow bg
} else {
std.debug.print("{d:>2} ", .{step + 1});
}
}
std.debug.print("\n", .{});
// Track rows
for (0..seq.NUM_TRACKS) |t| {
const sound = seq.tracks[t];
std.debug.print("{s} ", .{sound.label()});
for (0..seq.NUM_STEPS) |step| {
const active = s.grid[t][step];
const is_cursor = (t == cursor_track and step == cursor_step);
const is_playhead = (step == s.position and s.playing);
if (is_cursor) {
// Cursor position -- bright white background
if (active) {
std.debug.print("{s}97;42m {c} {s}0m", .{ ESC, sound.symbol(), ESC });
} else {
std.debug.print("{s}97;47m . {s}0m", .{ ESC, ESC });
}
} else if (active) {
if (is_playhead) {
std.debug.print("{s}1;33m [{c}]{s}0m", .{ ESC, sound.symbol(), ESC });
} else {
std.debug.print("{s}32m [{c}]{s}0m", .{ ESC, sound.symbol(), ESC });
}
} else {
if (is_playhead) {
std.debug.print("{s}90m . {s}0m", .{ ESC, ESC });
} else {
std.debug.print(" . ", .{});
}
}
}
std.debug.print("\n", .{});
}
// Now-playing indicator
std.debug.print("\n Now: ", .{});
const active = s.currentActive();
var any = false;
for (0..seq.NUM_TRACKS) |t| {
if (active[t]) {
if (any) std.debug.print(" + ", .{});
std.debug.print("{s}1m{s}{s}0m", .{ ESC, seq.tracks[t].label(), ESC });
any = true;
}
}
if (!any) std.debug.print("---", .{});
std.debug.print("\n", .{});
// Controls
std.debug.print("\n [arrows] move [space] toggle [p] play/stop [+/-] BPM\n", .{});
std.debug.print(" [l] load preset [c] clear [w] save [r] load [q] quit\n", .{});
}
pub fn printStep(s: seq.Sequencer) void {
const active = s.currentActive();
std.debug.print(" Step {d:>2}: ", .{s.position + 1});
var any = false;
for (0..seq.NUM_TRACKS) |t| {
if (active[t]) {
if (any) std.debug.print(" + ", .{});
std.debug.print("{s}", .{seq.tracks[t].label()});
any = true;
}
}
if (!any) std.debug.print("---", .{});
std.debug.print("\n", .{});
}
ANSI escape codes again -- same approach as the portfolio display from ep010 but more colourful. Green for active notes, yellow background for the playhead, bright white for the editor cursor. The display module only reads sequencer state -- it never modifies it. Clean separation.
The render function takes the sequencer by value (not pointer) plus the cursor position. This means the display code physically cannot mutate the sequencer. If someone accidentally writes s.position = 0 inside render, the compiler will reject it. The type system enforces our architecture.
One thing worth noting: all those std.debug.print calls write to stderr. For a terminal UI that's fine -- we're writing directly to the terminal, not piping output anywhere. If you wanted to make this a "proper" TUI you'd use stdout with buffered writes. For our purposes, debug.print is simpler and works great.
Save and Load
// src/storage.zig
const std = @import("std");
const seq = @import("sequencer.zig");
pub fn save(s: seq.Sequencer, path: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
const w = file.writer();
try w.print("STEPSEQ v1\n", .{});
try w.print("bpm {d}\n", .{s.bpm});
try w.print("tracks {d}\n", .{seq.NUM_TRACKS});
try w.print("steps {d}\n", .{seq.NUM_STEPS});
for (0..seq.NUM_TRACKS) |t| {
try w.print("track {c} ", .{seq.tracks[t].symbol()});
for (0..seq.NUM_STEPS) |step| {
try w.print("{c}", .{if (s.grid[t][step]) @as(u8, 'X') else @as(u8, '.')});
}
try w.print("\n", .{});
}
}
pub fn load(s: *seq.Sequencer, allocator: std.mem.Allocator, path: []const u8) !void {
const content = try std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024);
defer allocator.free(content);
var lines = std.mem.splitScalar(u8, content, '\n');
// Check header
const header = lines.next() orelse return error.InvalidFormat;
if (!std.mem.eql(u8, header, "STEPSEQ v1")) return error.InvalidFormat;
// Parse metadata
while (lines.next()) |line| {
if (line.len == 0) continue;
if (std.mem.startsWith(u8, line, "bpm ")) {
const bpm_str = line[4..];
s.bpm = std.fmt.parseInt(u32, bpm_str, 10) catch continue;
} else if (std.mem.startsWith(u8, line, "track ")) {
if (line.len < 8) continue;
const sym = line[6];
const pattern = line[8..];
// Find which track this symbol belongs to
var track_idx: ?usize = null;
for (0..seq.NUM_TRACKS) |t| {
if (seq.tracks[t].symbol() == sym) {
track_idx = t;
break;
}
}
if (track_idx) |t| {
const len = @min(pattern.len, seq.NUM_STEPS);
for (0..len) |step| {
s.grid[t][step] = (pattern[step] == 'X');
}
}
}
}
}
The saved file looks like this:
STEPSEQ v1
bpm 120
tracks 4
steps 16
track K X...X...X...X...
track S ....X.......X...
track H X.X.X.X.X.X.X.X.
track C ....X.......X...
Human-readable, editable in any text editor, versionable with git. Same philosophy as ep010's portfolio file -- text-based formats are debuggable. If your save file ever looks wrong, you can open it in nano and see exactly what happened. Try doing that with a binary format.
The save function takes Sequencer by value (read-only). The load function takes *Sequencer because it modifies the grid. The allocator parameter follows the same pattern from ep007 -- the caller provides the allocator, the function uses it temporarily for reading the file, and cleans up with defer allocator.free(content). No memory leaks.
Error handling uses try for things that should propagate (file creation, writing) and catch continue for things that should be skipped (malformed lines). Same distinction we've been making since ep004 -- propagate when the caller needs to know, handle locally when recovery is possible.
The Main Loop
// src/main.zig
const std = @import("std");
const seq = @import("sequencer.zig");
const display = @import("display.zig");
const storage = @import("storage.zig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var sequencer = seq.Sequencer{};
sequencer.loadPreset();
var cursor_track: usize = 0;
var cursor_step: usize = 0;
const original_termios = try enableRawMode();
defer disableRawMode(original_termios);
var running = true;
while (running) {
display.render(sequencer, cursor_track, cursor_step);
var buf: [3]u8 = undefined;
const n = try std.io.getStdIn().read(&buf);
if (n == 0) continue;
if (n == 1) {
switch (buf[0]) {
'q' => running = false,
' ' => sequencer.toggle(cursor_track, cursor_step),
'p' => {
sequencer.playing = !sequencer.playing;
if (sequencer.playing) sequencer.position = 0;
},
'n' => {
sequencer.advance();
display.printStep(sequencer);
},
'+', '=' => {
if (sequencer.bpm < 300) sequencer.bpm += 5;
},
'-' => {
if (sequencer.bpm > 40) sequencer.bpm -= 5;
},
'l' => sequencer.loadPreset(),
'c' => sequencer.clear(),
'w' => {
storage.save(sequencer, "pattern.seq") catch |err| {
std.debug.print("Save failed: {}\n", .{err});
};
},
'r' => {
storage.load(&sequencer, allocator, "pattern.seq") catch |err| {
std.debug.print("Load failed: {}\n", .{err});
};
},
else => {},
}
} else if (n == 3 and buf[0] == 27 and buf[1] == '[') {
// Arrow key escape sequences
switch (buf[2]) {
'A' => { // Up
if (cursor_track > 0) cursor_track -= 1;
},
'B' => { // Down
if (cursor_track < seq.NUM_TRACKS - 1) cursor_track += 1;
},
'C' => { // Right
if (cursor_step < seq.NUM_STEPS - 1) cursor_step += 1;
},
'D' => { // Left
if (cursor_step > 0) cursor_step -= 1;
},
else => {},
}
}
}
std.debug.print("\x1b[2J\x1b[H", .{});
std.debug.print("Sequencer closed.\n", .{});
}
fn enableRawMode() !std.posix.termios {
const fd = std.io.getStdIn().handle;
const orig = try std.posix.tcgetattr(fd);
var raw = orig;
raw.lflag.ECHO = false;
raw.lflag.ICANON = false;
raw.lflag.ISIG = false;
raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
try std.posix.tcsetattr(fd, .FLUSH, raw);
return orig;
}
fn disableRawMode(orig: std.posix.termios) void {
std.posix.tcsetattr(std.io.getStdIn().handle, .FLUSH, orig) catch {};
}
The main loop is straightforward: render, read a keypress, dispatch. Arrow keys for moving the cursor across the grid, space to toggle a cell, p to play/stop, n to manually advance one step (useful for building patterns one step at a time), +/- for tempo, w/r for save/load, l for the preset, c to clear, q to quit.
Raw terminal mode is the same tcgetattr/tcsetattr dance from the portfolio example in ep010. By disabling ECHO and ICANON, we get immediate single-keypress input without waiting for Enter. The defer disableRawMode(original_termios) guarantees we restore the terminal even if the program crashes -- same resource cleanup pattern we've been using with defer since ep004. If we didn't restore the terminal, your shell would be in a broken state after quitting. Not fun.
Arrow key handling -- arrow keys send a 3-byte escape sequence: ESC [ A (up), ESC [ B (down), ESC [ C (right), ESC [ D (left). We read up to 3 bytes at a time and check if it's an escape sequence. If it's a single byte, it's a regular key. If it's 3 bytes starting with 27, '[', it's an arrow key. This is how terminal input works on Unix -- no special library needed.
Notice that main.zig doesn't contain any rendering logic or file format knowledge. It owns the Sequencer, reads input, and calls functions from the other modules. If you wanted to replace the terminal display with a web UI, you'd change display.zig and nothing else. If you wanted to switch from text files to JSON, you'd change storage.zig and nothing else. Main just coordinates.
Every Concept in Action
Let me map out exactly where each episode's concepts show up:
| Episode | Where it's used |
|---|---|
| ep002: Variables & Types | const, var, u32, u8, bool, usize, f64, format strings |
| ep003: Functions & Control Flow | All methods, switch on keypresses, while loop, if/else |
| ep004: Error Handling | try/catch on file ops, defer for cleanup, error unions |
| ep005: Arrays & Slices | 2D grid array, string slices in labels, [_]bool{false} ** N |
| ep006: Structs & Enums | Sequencer struct, Sound enum with methods |
| ep007: Allocators | GPA in main, allocator passed to storage.load |
| ep008: Pointers | *Sequencer for mutation, *Self methods |
| ep009: Comptime | Default array values via **, compile-time enum methods |
| ep010: Modules & I/O | Four-file architecture, @import, file save/load, splitScalar |
This is the payoff for building foundations properly. None of the code above should feel mysterious. Every pattern, every keyword, every built-in function has been covered in a previous episode. The only new thing is combining them into a larger program.
The Architecture
Let me be explicit about the design, because this pattern scales to much larger programs:
main.zig (coordinator)
|-- owns Sequencer instance
|-- reads keyboard input
|-- dispatches to modules
|
+-- sequencer.zig (engine)
| pure data + logic
| no I/O, no display
|
+-- display.zig (renderer)
| reads Sequencer state
| writes to terminal
| never modifies state
|
+-- storage.zig (persistence)
reads/writes files
uses Sequencer types
doesn't know about display
Each module has one job. The dependencies flow in one direction: display.zig and storage.zig both @import("sequencer.zig") for the types, but they don't import each other. main.zig imports all three and wires them together. No circular dependencies. No module importing something that eventually imports it back.
This isn't Zig-specific architecture -- it's good software engineering. But Zig makes it natural. Every file is a module. pub controls what's visible. The compiler catches you if you try to access something private. And because @import is a comptime operation, there's zero runtime overhead to this modularity. The compiled binary is exactly the same as if everything were in one file. You get organizational clearity for free.
Running the Sequencer
The full project structure:
step-sequencer/
build.zig
build.zig.zon
src/
main.zig
sequencer.zig
display.zig
storage.zig
Build and run:
zig build run
You'll see the grid rendered in your terminal. Arrow keys to move the cursor, space to toggle notes on/off, p to start "playback" (the position marker sweeps through the grid), n to step through manually. Press w to save your pattern to pattern.seq, r to load it back. l loads the demo preset. q to quit.
Try building a pattern by hand: toggle on kick on steps 1, 5, 9, 13 (four-on-the-floor), snare on steps 5 and 13 (backbeat), hihats everywhere. Then step through it with n and watch the "Now:" line show which instruments fire on each beat. That's your sequencer working.
What Stays on the Stack
One thing I want to highlight: the entire program allocates almost nothing on the heap. The Sequencer struct with its 4x16 boolean grid, the cursor position, the BPM -- all on the stack. The only heap allocation happens when storage.load reads a file with readFileAlloc, and that's freed immediately after parsing with defer.
In Python, every boolean in that grid would be a separate heap object. Every string would be a heap object. The grid itself would be a list of lists of heap-allocated booleans. In Zig, it's a flat [4][16]bool = 64 bytes, contigious in memory, zero indirection. When the program exits, there's nothing to garbage collect because there's almost nothing on the heap to begin with.
This isn't premature optimization -- it's the natural result of using value types and fixed-size arrays. We didn't go out of our way to avoid allocation. We just used the simplest data structures that fit the problem, and those happen to live on the stack. That's Zig's design philosophy at work.
Extending the Sequencer
The version above is deliberately minimal. Here are some directions you could take it:
More tracks: Change NUM_TRACKS to 8 and add more sounds to the Sound enum. Because the grid size is based on constants, the compiler will adjust everything automatically. Try it -- the only code you need to change is the enum and the tracks array.
Real audio: Link a C audio library (like portaudio or miniaudio) through build.zig, exactly as we discussed in ep010's C interop section. The engine already tells you which sounds to trigger on each step -- you just need to play the actual samples.
Pattern chaining: Instead of one pattern, have an array of patterns and switch between them. A [4]Sequencer would give you four patterns to chain into a song.
Swing: Instead of perfectly even step timing, alternate between slightly longer and shorter intervals. A single swing: f64 field (0.0 = straight, 0.5 = max swing) and a small modification to stepDelayMs would do it.
The point isn't to build all of these now. The point is that the architecture supports them. Because the modules are cleanly separated, each extension touches one or two files, not the entire codebase. That's the value of good architecture -- it makes future work easier.
Exercises
These exercises build on the step sequencer. You should have the complete project working before attempting them.
Add a fifth track (e.g.
cowbellorbass) to the sequencer. This requires changing theSoundenum, thetracksarray, andNUM_TRACKS. Verify that the display adjusts automatically.Add a "swing" feature: a
swingfield (0.0 to 0.5) on theSequencerstruct, and modifystepDelayMsto return alternating long/short delays. Odd steps should bebase_delay * (1.0 + swing)and even stepsbase_delay * (1.0 - swing). Add keyboard controls to adjust swing up/down.Add pattern export to a different format: write a function in
storage.zigthat exports the pattern as a simple ASCII art display (the grid withXand.characters, no metadata headers). The output should be printable -- pipe it to> pattern.txtand you get a human-readable diagram.Implement
undo: keep a "previous grid state" copy in the sequencer. Before everytogglecall, copy the current grid to the backup. Add aukey binding that swaps current and backup. One level of undo is enough -- no need for a full history stack.Add a
mutefeature per track: a[NUM_TRACKS]boolarray for mute state. Muted tracks still show their pattern in the grid but don't appear in the "Now:" display. Addm+ arrow key to toggle mute on the current track. Display muted tracks in a dimmer color.Implement pattern copy: add a second
Sequenceras a clipboard. Keyycopies the current pattern to the clipboard, keyvpastes the clipboard pattern into the current sequencer. This exercises struct assignment -- in Zig,clipboard = sequencercopies all fields by value (remember value semantics from ep006?).
Exercises 1-2 modify the engine. Exercise 3 extends storage. Exercises 4-6 add features that require coordinating multiple modules. All of them practice the architecture patterns from this episode.
What's Ahead
We've now covered all the core language features AND built a real multi-file project that uses them together. That's a solid foundation. But there's more to Zig than what we've seen so far -- testing (Zig has built-in test support, no external framework needed), interfaces via type erasure, generics with comptime parameters, the build system in depth, and quite some patterns for working with the standard library that become important as your programs grow.
Having said that, the biggest takeaway from this episode isn't any specific technique. It's this: the ten concepts we covered in eps 1-10 are enough to build real software. Not toy examples. Not "hello world" variations. Actual programs that read files, process data, render output, and handle errors. Zig's power comes from a small number of orthogonal features that compose well. You don't need to learn 50 different things -- you need to learn 10 things deeply and combine them.
Build something. Anything. A todo list. A file converter. A simple game. A data processor. The best way to internalize these patterns is to use them on a problem you care about. The compiler will teach you the rest ;-)