tags: #zig #wasm-4 #gamedev
Of course! Here is a detailed tutorial for learning Zig by creating a small game for the WASM-4 fantasy console. This tutorial is designed to be followed sequentially, introducing Zig concepts as they become necessary for building the game. We'll be making a classic Snake game.
Welcome! This tutorial will guide you through building a complete Snake game from scratch using the Zig programming language. We'll be targeting WASM-4, a fantasy console for creating small, retro-style games with WebAssembly.
This is a perfect environment for learning Zig because:
- It's a constrained environment: With only 64KB of RAM and a 160x160 pixel screen, you can focus on language fundamentals without getting lost in complex frameworks.
- Zig shines here: Zig's low-level control, lack of a hidden runtime, and excellent WebAssembly support make it a first-class citizen for this kind of development.
- It's practical: You'll apply core Zig concepts like memory management, structs, functions, and C interop to build something tangible and fun.
By the end of this tutorial, you will have learned:
- How to set up a Zig project for WebAssembly.
- The basics of Zig syntax: variables, functions, and structs.
- How to interact with a C-style API from Zig.
- How to manage memory and state in a Zig application.
- How to handle user input and render graphics.
- How to build and package your game for distribution.
Let's get started!
First, we need to set up our development environment and create a new WASM-4 project.
- Install Zig: Follow the official instructions at ziglang.org/learn/getting-started/. A recent version (0.11.0 or newer) is recommended.
- Install the
w4
CLI: Download the WASM-4 command-line tool for your operating system from the WASM-4 homepage. Make sure it's in your system's PATH.
Open your terminal and run the w4
command to create a new Zig project named snake
:
w4 new --zig snake
cd snake
This command creates a new directory named snake
with the following structure:
snake/
├── .gitignore
├── build.zig <-- The build script for your game
└── src/
└── main.zig <-- Your game's main source file
The build.zig
file tells the Zig compiler how to build your project. The template provides a good starting point, but let's make one small but important change. WASM-4 cartridges should be as small as possible. Open build.zig
and ensure the .optimize
mode is set to ReleaseSmall
.
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
// Change this line to ReleaseSmall for the smallest binary
const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSmall });
const exe = b.addExecutable(.{
.name = "cart",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// ... rest of the file
}
Now, let's build and run the project. The w4 watch
command will automatically compile your code and reload the game in a web browser whenever you save a file.
w4 watch
Your browser should open to http://localhost:4444/
and display the "Hello, World!" template.
Let's look at the starting code in src/main.zig
.
const w4 = @import("wasm4.zig");
export fn update() void {
w4.trace("Hello from Zig!");
}
const w4 = @import("wasm4.zig");
: This imports a local file namedwasm4.zig
which contains the WASM-4 API bindings. We'll create this file next.@import
is Zig's way of including other source files.export fn update() void { ... }
: This defines and exports a function namedupdate
. Theexport
keyword makes it visible to the outside world (in this case, the WASM-4 runtime). The runtime calls this function 60 times per second.
Our first step is to create the wasm4.zig
file that main.zig
is trying to import.
WASM-4 provides its API through a set of imported functions and memory-mapped registers (specific memory addresses that control the console's hardware). To use these from Zig, we need to declare them.
Create a new file src/wasm4.zig
. This file will act as our library for all WASM-4 specific functionality.
According to the WASM-4 docs, the color palette and drawing colors are controlled by registers at memory addresses 0x04
and 0x14
. We can create pointers to these locations in Zig.
// src/wasm4.zig
const builtin = @import("builtin");
// PALETTE is at memory address 0x04. It's an array of 4 colors (u32).
pub const PALETTE = @ptrFromInt(0x04).(*[4]u32);
// DRAW_COLORS is at memory address 0x14. It's a 16-bit integer (u16).
pub const DRAW_COLORS = @ptrFromInt(0x14).(*u16);
@ptrFromInt(address)
: This is a built-in Zig function that converts a raw integer address into a pointer..(*[4]u32)
: This is a pointer cast. We're telling Zig to treat the pointer as a "pointer to an array of 4u32
s".pub
: This keyword makes thePALETTE
andDRAW_COLORS
constants public, somain.zig
can access them.
WASM-4 provides drawing functions like rect()
. We declare these as extern
functions.
// src/wasm4.zig (continued)
// ... (register declarations from above)
// Declare the rect function imported from the WASM-4 runtime.
pub extern fn rect(x: i32, y: i32, width: u32, height: u32) void;
extern fn
tells Zig that the implementation for this function will be provided by the host environment at runtime.
Now, let's modify src/main.zig
to set up a color palette and draw a rectangle. WASM-4 calls the start()
function once when the cartridge loads, which is the perfect place for setup.
// src/main.zig
const w4 = @import("wasm4.zig");
// The start function is called once at the beginning of the game.
export fn start() void {
// Set up our color palette. Colors are in 0xRRGGBB format.
// We will use a nice green/brown theme.
w4.PALETTE.* = .{
0xfbf7f3, // Color 1: Cream (background)
0xe5b083, // Color 2: Tan (fruit)
0x426e5d, // Color 3: Dark Green (snake body)
0x20283d, // Color 4: Dark Blue (snake head)
};
}
export fn update() void {
// Set the drawing colors. 0x43 means:
// - Color 1 (fill) uses palette entry 3 (Dark Green)
// - Color 2 (outline) uses palette entry 4 (Dark Blue)
// - Colors 3 and 4 are transparent (0)
w4.DRAW_COLORS.* = 0x43;
// Draw a 10x10 rectangle at position (20, 20)
w4.rect(20, 20, 10, 10);
}
w4.PALETTE.* = .{ ... };
: We use.*
to dereference the pointer and assign a new value to the memory it points to. The.{ ... }
syntax is an anonymous struct/array literal, which Zig can coerce into the correct type ([4]u32
).w4.DRAW_COLORS.* = 0x43;
: We dereference theDRAW_COLORS
pointer and set its value.
Save your files. w4 watch
should recompile, and your browser will now show a small, dark green rectangle with a blue outline. You've successfully called the WASM-4 API from Zig!
A snake is a series of connected segments. We need a way to represent this in our code. Structs are the perfect tool for this.
Each segment of the snake has an X and Y coordinate. Let's create a Point
struct to hold this.
// src/main.zig
// ... (const w4 = ...)
const Point = struct {
x: i32,
y: i32,
};
This defines a new type Point
with two fields, x
and y
, both of which are 32-bit signed integers.
The snake itself has a body (a list of Point
s) and a direction.
// src/main.zig
// ...
const std = @import("std"); // Import the standard library
// ... (Point struct)
const Snake = struct {
body: std.ArrayList(Point),
direction: Point,
// This is an init function, a common pattern in Zig for creating instances of a struct.
pub fn init(allocator: std.mem.Allocator) Snake {
var snake = Snake{
.body = std.ArrayList(Point).init(allocator),
.direction = Point{ .x = 1, .y = 0 }, // Start moving right
};
// Add the initial body segments.
// `try` will propagate an OutOfMemory error if allocation fails.
// Since we are using a fixed buffer, this is safe.
snake.body.appendSlice(&.{
Point{ .x = 2, .y = 0 },
Point{ .x = 1, .y = 0 },
Point{ .x = 0, .y = 0 },
}) catch @panic("Failed to initialize snake body");
return snake;
}
// A deinit function to free the memory used by the ArrayList
pub fn deinit(self: *Snake) void {
self.body.deinit();
}
};
Here we introduce two important Zig concepts:
- The Standard Library (
std
): We@import("std")
to get access to useful data structures. std.ArrayList
: A dynamic array. It needs an allocator to manage its memory.
WASM-4 doesn't have a system heap like a desktop OS. We must provide the memory ourselves. A FixedBufferAllocator
is perfect: it uses a pre-allocated, fixed-size block of memory (a static array).
Let's set up our global state in main.zig
.
// src/main.zig
// ... (imports and structs)
// A buffer to be used by our allocator. 1600 bytes is enough for a 20x20 grid (400 points).
var memory_buffer: [1600]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&memory_buffer);
const allocator = fba.allocator();
// Our global game state
var snake = Snake.init(allocator);
var fruit = Point{ .x = 10, .y = 10 };
var frame_count: u32 = 0;
// ... (start and update functions)
Now we have a snake
variable ready to be used.
With the Snake
struct defined, we can now draw it. This is a great opportunity to use a for
loop.
Add a draw
method to the Snake
struct:
// In the Snake struct in src/main.zig
const Snake = struct {
// ... (fields and init function)
pub fn draw(self: *const Snake) void {
// Draw the body (dark green)
w4.DRAW_COLORS.* = 0x33;
for (self.body.items) |part| {
w4.rect(part.x * 8, part.y * 8, 8, 8);
}
// Draw the head (dark blue)
const head = self.body.items[0];
w4.DRAW_COLORS.* = 0x44;
w4.rect(head.x * 8, head.y * 8, 8, 8);
}
// ... (deinit function)
};
self: *const Snake
: The first argument to a method is conventionallyself
.*const
means it's a read-only pointer to the struct instance.for (self.body.items) |part| { ... }
: This is how you loop over a slice in Zig.self.body.items
is the slice ofPoint
s from ourArrayList
.part
will hold eachPoint
for each iteration.- We draw each segment as an 8x8 rectangle. We multiply the coordinates by 8 to scale them up to the screen.
Now, call this draw
method from update()
:
// src/main.zig
export fn update() void {
snake.draw();
}
Run it, and you'll see your initial three-segment snake on the screen!
A snake needs to move. We'll create an update
method for the Snake
struct to handle its movement logic.
The logic is simple:
- Add a new head in the current direction.
- Remove the last segment from the tail.
// In the Snake struct in src/main.zig
const Snake = struct {
// ...
pub fn update(self: *Snake) void {
const old_head = self.body.items[0];
const new_head = Point{
.x = old_head.x + self.direction.x,
.y = old_head.y + self.direction.y,
};
// Insert the new head at the beginning of the list.
self.body.insert(0, new_head) catch @panic("Snake too long!");
// Remove the last element from the tail.
_ = self.body.pop();
}
// ...
};
self: *Snake
: Theupdate
method needs to modify the snake, so we take a mutable pointer*Snake
.self.body.insert(0, new_head)
adds the new head.self.body.pop()
removes and returns the last element. We assign it to_
to explicitly ignore the returned value.
WASM-4 runs at 60 FPS, which is too fast for a snake game. We'll use our frame_count
variable to only update the snake every 10 frames.
// src/main.zig
export fn update() void {
frame_count += 1;
// Only update snake logic every 10 frames
if (frame_count % 10 == 0) {
snake.update();
}
snake.draw();
}
Now your snake glides smoothly across the screen! But it flies right off the edge. Let's add wrapping logic.
// In snake.update()
const new_head = Point{
.x = @mod(old_head.x + self.direction.x, 20),
.y = @mod(old_head.y + self.direction.y, 20),
};
@mod(a, b)
: This is Zig's built-in modulo function that handles negative numbers correctly, making it perfect for screen wrapping. Our grid is 20x20 (160 / 8
).
A game isn't a game without input. Let's make the snake controllable. We need to read the GAMEPAD1
register.
First, add the register to src/wasm4.zig
:
// src/wasm4.zig
pub const GAMEPAD1 = @ptrFromInt(0x16).(*const u8);
// Button constants
pub const BUTTON_UP: u8 = 64;
pub const BUTTON_DOWN: u8 = 128;
pub const BUTTON_LEFT: u8 = 16;
pub const BUTTON_RIGHT: u8 = 32;
Now, create an input()
function in main.zig
and call it from update()
.
// src/main.zig
// ...
fn input() void {
const gamepad = w4.GAMEPAD1.*;
if (gamepad & w4.BUTTON_UP != 0 and snake.direction.y == 0) {
snake.direction = Point{ .x = 0, .y = -1 };
}
if (gamepad & w4.BUTTON_DOWN != 0 and snake.direction.y == 0) {
snake.direction = Point{ .x = 0, .y = 1 };
}
if (gamepad & w4.BUTTON_LEFT != 0 and snake.direction.x == 0) {
snake.direction = Point{ .x = -1, .y = 0 };
}
if (gamepad & w4.BUTTON_RIGHT != 0 and snake.direction.x == 0) {
snake.direction = Point{ .x = 1, .y = 0 };
}
}
export fn update() void {
input(); // Handle input every frame
frame_count += 1;
if (frame_count % 10 == 0) {
snake.update();
}
snake.draw();
}
gamepad & w4.BUTTON_UP != 0
: We use the bitwise AND operator&
to check if a specific button's bit is set in thegamepad
byte.and snake.direction.y == 0
: This clever check prevents the snake from reversing on itself. You can't go up if you're already moving up or down.
You can now control the snake with the arrow keys!
A snake's gotta eat. Let's add a fruit and collision detection.
We need to place the fruit randomly. Zig's standard library provides a pseudo-random number generator (PRNG). We already have our fruit
variable, let's update it.
Add these variables to the top of main.zig
:
var prng = std.rand.DefaultPrng.init(0); // Seed with 0 for now
const random = prng.random();
Now, in start()
, let's place the first fruit.
// src/main.zig
export fn start() void {
// ... (palette setup)
fruit.x = random.intRangeLessThan(i32, 0, 20);
fruit.y = random.intRangeLessThan(i32, 0, 20);
}
random.intRangeLessThan(Type, min, max)
: Generates a random number ofType
in the range[min, max)
.
In update()
, draw the fruit.
// In update()
w4.DRAW_COLORS.* = 0x22; // Use tan color
w4.rect(fruit.x * 8, fruit.y * 8, 8, 8);
In snake.update()
, check for collision with the fruit.
// In snake.update()
const old_head = self.body.items[0];
const new_head = ...;
var ate_fruit = false;
if (new_head.x == fruit.x and new_head.y == fruit.y) {
ate_fruit = true;
fruit.x = random.intRangeLessThan(i32, 0, 20);
fruit.y = random.intRangeLessThan(i32, 0, 20);
}
self.body.insert(0, new_head) catch @panic("...");
// Only pop the tail if we didn't eat a fruit
if (!ate_fruit) {
_ = self.body.pop();
}
Now when the snake's head moves onto the fruit's tile, the fruit moves to a new random location, and the snake grows by one segment because its tail is not removed.
The last piece of the puzzle is the "Game Over" condition: the snake colliding with its own body.
Add a new method to the Snake
struct.
// In Snake struct
pub fn checkSelfCollision(self: *const Snake) bool {
const head = self.body.items[0];
// Start at 1 to skip checking the head against itself
for (self.body.items[1..]) |part| {
if (head.x == part.x and head.y == part.y) {
return true;
}
}
return false;
}
self.body.items[1..]
: This creates a slice of the body that excludes the first element (the head).
We need a way to track whether we're playing or the game is over. An enum
is perfect for this.
// At the top of main.zig
const GameState = enum { Playing, GameOver };
var game_state: GameState = .Playing;
Now, let's use this state in our main loop.
// src/main.zig
export fn update() void {
switch (game_state) {
.Playing => {
input();
frame_count += 1;
if (frame_count % 10 == 0) {
snake.update();
if (snake.checkSelfCollision()) {
game_state = .GameOver;
}
}
// Drawing logic
w4.DRAW_COLORS.* = 0x11; // Clear screen with background color
w4.rect(0, 0, 160, 160);
snake.draw();
w4.DRAW_COLORS.* = 0x22;
w4.rect(fruit.x * 8, fruit.y * 8, 8, 8);
},
.GameOver => {
w4.trace("GAME OVER! Press X to restart.");
const gamepad = w4.GAMEPAD1.*;
if (gamepad & w4.BUTTON_1 != 0) {
// Reset the game
snake.deinit();
snake = Snake.init(allocator);
game_state = .Playing;
}
},
}
}
switch (game_state) { ... }
: Aswitch
statement lets us execute different code based on the value ofgame_state
.- When the snake collides with itself, we change the state to
.GameOver
. - In the
.GameOver
state, we check for a button press to reset the game by re-initializing the snake. - We need the
w4.trace
function, so addpub extern fn trace(str: [*:0]const u8) void;
towasm4.zig
. The[*:0]const u8
type is a C-style null-terminated string.
Congratulations! You have a complete, working Snake game written in Zig.
Your game is a .wasm
file. To share it, you can bundle it into a self-contained HTML file.
First, build the final optimized cartridge:
zig build -Doptimize=ReleaseSmall
This will create zig-out/bin/cart.wasm
.
Now, use the w4
tool to bundle it:
w4 bundle zig-out/bin/cart.wasm --title "Zig Snake" --html snake.html
You now have a snake.html
file that you can share with anyone, or upload to platforms like itch.io!
You've learned the fundamentals of Zig by building a real project. From here, you can:
- Add features: Implement a scoring system, sound effects using
w4.tone()
, or a title screen. - Explore more of Zig: Dive deeper into the standard library, explore
comptime
for compile-time code execution, or learn about error handling withtry
andcatch
. - Build another game: Try making Pong, a platformer, or your own unique idea!
The official Zig documentation is an excellent resource for exploring the language in more depth. Happy coding
Of course! Here is Part 2 of the Zig tutorial, building upon the Snake game we created. This part will introduce more advanced concepts and add classic game features.
Welcome back! In Part 1, you built a fully functional Snake game. Now, it's time to add the polish that makes a game feel complete. We'll add a title screen, a scoring system, and sound effects.
Along the way, we'll explore more powerful features of the Zig language:
- The Standard Library: We'll use the
std.fmt
module to format text. comptime
: You'll see how compile-time execution helps create clean, efficient code.- Error Handling: We'll refactor our code to handle potential errors gracefully with
try
andcatch
. - Sprites: We'll replace our simple rectangle fruit with a proper sprite image.
Let's begin!
Every game needs a title screen. This requires us to expand our concept of the "game state" and learn how to draw text.
First, let's update our GameState
enum in src/main.zig
to include a state for the title screen.
// src/main.zig
const GameState = enum {
Title,
Playing,
GameOver,
};
var game_state: GameState = .Title; // Start at the title screen
To draw text, we need to declare the w4.text()
function in our API bindings. This function takes a C-style, null-terminated string.
// src/wasm4.zig
// ... other declarations
// text() takes a C-style null-terminated string: [*:0]const u8
pub extern fn text(str: [*:0]const u8, x: i32, y: i32) void;
The type [*:0]const u8
is Zig's way of representing a pointer to a constant, null-terminated array of u8
bytes. String literals in Zig, like "Hello"
, automatically have this type.
Now, let's add the .Title
case to our switch
statement in the update
function.
// src/main.zig
export fn update() void {
switch (game_state) {
.Title => {
w4.DRAW_COLORS.* = 0x4321; // Set all 4 colors
w4.text("ZIG SNAKE", 48, 60);
w4.text("Press X to Start", 28, 80);
const gamepad = w4.GAMEPAD1.*;
// w4.BUTTON_1 is the 'X' button on a controller
if (gamepad & w4.BUTTON_1 != 0) {
game_state = .Playing;
}
},
.Playing => {
// ... (our existing game logic)
},
.GameOver => {
// ... (our existing game over logic)
},
}
}
We also need to add BUTTON_1
to our constants in src/wasm4.zig
:
// src/wasm4.zig
pub const BUTTON_1: u8 = 1;
Run w4 watch
again. You'll now be greeted by a title screen! Pressing the 'X' key (the actual 'X' key on your keyboard) will start the game.
What's a game without a score? Let's add a score that increases every time the snake eats a fruit. This is a great opportunity to explore Zig's powerful string formatting capabilities.
First, add a score
variable to our global state.
// src/main.zig
// ... (other global variables)
var score: u32 = 0;
Next, in the snake.update()
method, increment the score when a fruit is eaten.
// in snake.update()
if (new_head.x == fruit.x and new_head.y == fruit.y) {
ate_fruit = true;
score += 10; // Add 10 points for each fruit
// ... (rest of the logic)
}
Now for the interesting part. We have an integer score
, but w4.text
needs a string. We need to format the integer into a string. Zig's standard library provides std.fmt.bufPrint
for this.
std.fmt.bufPrint
is a safe and efficient way to format data. It takes three arguments:
- A mutable byte slice (our buffer) to write the formatted string into.
- A compile-time known format string.
- A tuple of arguments to be formatted.
Let's use it in our .Playing
game state logic.
// in the .Playing case of the update() function
.Playing => {
// ... (input, update, etc.)
// --- Drawing Logic ---
w4.DRAW_COLORS.* = 0x11; // Clear screen
w4.rect(0, 0, 160, 160);
snake.draw();
w4.DRAW_COLORS.* = 0x22; // Draw fruit
w4.rect(fruit.x * 8, fruit.y * 8, 8, 8);
// --- Draw the score ---
w4.DRAW_COLORS.* = 0x43;
// Create a buffer on the stack to hold the formatted string.
var score_buffer: [32]u8 = undefined;
// Format the score into the buffer.
const score_text = std.fmt.bufPrint(&score_buffer, "Score: {d}", .{score})
catch "Error"; // In case of error, display "Error"
w4.text(score_text, 5, 5);
},
var score_buffer: [32]u8 = undefined;
: We create a small array on the stack to act as our buffer.undefined
means we don't care about its initial contents.std.fmt.bufPrint(&score_buffer, "Score: {d}", .{score})
: This is the core of it.&score_buffer
: A slice of our buffer."Score: {d}"
: The format string.{d}
means format the argument as a decimal integer..{score}
: An anonymous tuple containing the arguments to format.
catch "Error"
:bufPrint
returns an error if the buffer is too small. We usecatch
to provide a fallback value. In a real application, you might handle this more robustly, but for our game, this is fine.
Now when you play and eat a fruit, your score appears in the top-left corner!
Of course! Here is Part 2 of the Zig tutorial, building upon the Snake game we created. This part will introduce more advanced concepts and add classic game features.
Welcome back! In Part 1, you built a fully functional Snake game. Now, it's time to add the polish that makes a game feel complete. We'll add a title screen, a scoring system, and sound effects.
Along the way, we'll explore more powerful features of the Zig language:
- The Standard Library: We'll use the
std.fmt
module to format text. comptime
: You'll see how compile-time execution helps create clean, efficient code.- Error Handling: We'll refactor our code to handle potential errors gracefully with
try
andcatch
. - Sprites: We'll replace our simple rectangle fruit with a proper sprite image.
Let's begin!
Every game needs a title screen. This requires us to expand our concept of the "game state" and learn how to draw text.
First, let's update our GameState
enum in src/main.zig
to include a state for the title screen.
// src/main.zig
const GameState = enum {
Title,
Playing,
GameOver,
};
var game_state: GameState = .Title; // Start at the title screen
To draw text, we need to declare the w4.text()
function in our API bindings. This function takes a C-style, null-terminated string.
// src/wasm4.zig
// ... other declarations
// text() takes a C-style null-terminated string: [*:0]const u8
pub extern fn text(str: [*:0]const u8, x: i32, y: i32) void;
The type [*:0]const u8
is Zig's way of representing a pointer to a constant, null-terminated array of u8
bytes. String literals in Zig, like "Hello"
, automatically have this type.
Now, let's add the .Title
case to our switch
statement in the update
function.
// src/main.zig
export fn update() void {
switch (game_state) {
.Title => {
w4.DRAW_COLORS.* = 0x4321; // Set all 4 colors
w4.text("ZIG SNAKE", 48, 60);
w4.text("Press X to Start", 28, 80);
const gamepad = w4.GAMEPAD1.*;
// w4.BUTTON_1 is the 'X' button on a controller
if (gamepad & w4.BUTTON_1 != 0) {
game_state = .Playing;
}
},
.Playing => {
// ... (our existing game logic)
},
.GameOver => {
// ... (our existing game over logic)
},
}
}
We also need to add BUTTON_1
to our constants in src/wasm4.zig
:
// src/wasm4.zig
pub const BUTTON_1: u8 = 1;
Run w4 watch
again. You'll now be greeted by a title screen! Pressing the 'X' key (the actual 'X' key on your keyboard) will start the game.
What's a game without a score? Let's add a score that increases every time the snake eats a fruit. This is a great opportunity to explore Zig's powerful string formatting capabilities.
First, add a score
variable to our global state.
// src/main.zig
// ... (other global variables)
var score: u32 = 0;
Next, in the snake.update()
method, increment the score when a fruit is eaten.
// in snake.update()
if (new_head.x == fruit.x and new_head.y == fruit.y) {
ate_fruit = true;
score += 10; // Add 10 points for each fruit
// ... (rest of the logic)
}
Now for the interesting part. We have an integer score
, but w4.text
needs a string. We need to format the integer into a string. Zig's standard library provides std.fmt.bufPrint
for this.
std.fmt.bufPrint
is a safe and efficient way to format data. It takes three arguments:
- A mutable byte slice (our buffer) to write the formatted string into.
- A compile-time known format string.
- A tuple of arguments to be formatted.
Let's use it in our .Playing
game state logic.
// in the .Playing case of the update() function
.Playing => {
// ... (input, update, etc.)
// --- Drawing Logic ---
w4.DRAW_COLORS.* = 0x11; // Clear screen
w4.rect(0, 0, 160, 160);
snake.draw();
w4.DRAW_COLORS.* = 0x22; // Draw fruit
w4.rect(fruit.x * 8, fruit.y * 8, 8, 8);
// --- Draw the score ---
w4.DRAW_COLORS.* = 0x43;
// Create a buffer on the stack to hold the formatted string.
var score_buffer: [32]u8 = undefined;
// Format the score into the buffer.
const score_text = std.fmt.bufPrint(&score_buffer, "Score: {d}", .{score})
catch "Error"; // In case of error, display "Error"
w4.text(score_text, 5, 5);
},
var score_buffer: [32]u8 = undefined;
: We create a small array on the stack to act as our buffer.undefined
means we don't care about its initial contents.std.fmt.bufPrint(&score_buffer, "Score: {d}", .{score})
: This is the core of it.&score_buffer
: A slice of our buffer."Score: {d}"
: The format string.{d}
means format the argument as a decimal integer..{score}
: An anonymous tuple containing the arguments to format.
catch "Error"
:bufPrint
returns an error if the buffer is too small. We usecatch
to provide a fallback value. In a real application, you might handle this more robustly, but for our game, this is fine.
Now when you play and eat a fruit, your score appears in the top-left corner!
Sound makes a game feel much more interactive. Let's add sound effects for eating a fruit and for game over.
First, add the tone()
function to src/wasm4.zig
.
// src/wasm4.zig
pub extern fn tone(
frequency: u32,
duration: u32,
volume: u32,
flags: u32,
) void;
// Sound channels
pub const TONE_PULSE1: u32 = 0;
pub const TONE_NOISE: u32 = 3;
Now, let's make a sound when the fruit is eaten.
// in snake.update()
if (new_head.x == fruit.x and new_head.y == fruit.y) {
// ... (score and fruit logic)
// Play a high-pitched "blip" sound.
// Freq=880Hz, Duration=10 frames, Volume=80, Channel=Pulse1
w4.tone(880, 10, 80, w4.TONE_PULSE1);
}
And a sound for game over. We can make a descending sound by specifying a start and end frequency. The second frequency is packed into the high 16 bits of the frequency
parameter.
// in the .Playing state of update()
if (snake.checkSelfCollision()) {
game_state = .GameOver;
// Play a low, descending "buzz" sound.
// Freq slides from 220Hz to 110Hz, Duration=30 frames, Vol=100, Channel=Noise
w4.tone((110 << 16) | 220, 30, 100, w4.TONE_NOISE);
}
Your game now has sound!
Our fruit is just a plain square. Let's replace it with a proper sprite! This is a great chance to use the w4 png2src
tool and learn about Zig's comptime
feature.
- Create an 8x8 pixel image of a fruit (e.g., an apple) in your favorite pixel art editor. Make sure it uses indexed color with at most 4 colors. Save it as
fruit.png
in your project's root directory. - Run the
w4
tool from your terminal:w4 png2src --zig fruit.png
- This will output Zig code to your terminal. It will look something like this:
const fruit_width = 8; const fruit_height = 8; const fruit_flags = 1; // BLIT_2BPP const fruit = [16]u8{ 0x00,0xa0,0x02,0x00,0x0e,0xf0,0x36,0x5c,0xd6,0x57,0xd5,0x57,0x35,0x5c,0x0f,0xf0 };
Copy that output into src/main.zig
. Instead of const
, let's use comptime
for the sprite properties. This tells the Zig compiler these values are known at compile-time and can be used in places where a constant value is required. It's a way of ensuring no "magic numbers" are in our code.
// src/main.zig
// ...
comptime {
// These values are known at compile time.
var FRUIT_WIDTH: u32 = 8;
var FRUIT_HEIGHT: u32 = 8;
var FRUIT_FLAGS: u32 = w4.BLIT_2BPP;
}
const fruit_sprite = [16]u8{ 0x00,0xa0,0x02,0x00,0x0e,0xf0,0x36,0x5c,0xd6,0x57,0xd5,0x57,0x35,0x5c,0x0f,0xf0 };
We also need to add the blit
function and the BLIT_2BPP
flag to wasm4.zig
.
// src/wasm4.zig
pub extern fn blit(
sprite: [*]const u8,
x: i32,
y: i32,
width: u32,
height: u32,
flags: u32,
) void;
pub const BLIT_2BPP: u32 = 1;
Finally, replace the w4.rect
call for the fruit with w4.blit
:
// In the .Playing case of update()
// ... (snake.draw())
// Draw fruit using the sprite
w4.DRAW_COLORS.* = 0x4321; // Set colors for the 2BPP sprite
w4.blit(&fruit_sprite, fruit.x * 8, fruit.y * 8, FRUIT_WIDTH, FRUIT_HEIGHT, FRUIT_FLAGS);
Your game now has a much nicer-looking fruit!
Currently, our code for growing the snake looks like this:
self.body.insert(0, new_head) catch @panic("Snake too long!");
This works, but it's a bit blunt. What if we wanted to handle the "out of memory" case more gracefully? The insert
function on an ArrayList
returns an error union, !void
. This means it can either succeed (returning void
) or fail (returning an error
).
Let's refactor our snake.update
method to properly propagate this error.
-
Change the function signature: Modify
snake.update
to indicate it can return an error.// In Snake struct pub fn update(self: *Snake) !void { // The ! means it can return an error
-
Use
try
: Replace thecatch @panic
with thetry
keyword.try
is a shortcut: if the expression returns an error,try
immediately returns that error from the current function. If it succeeds, it unwraps the value.// In snake.update() try self.body.insert(0, new_head); if (!ate_fruit) { _ = self.body.pop(); }
-
Handle the error at the call site: Now, back in the main
update
function, the call tosnake.update()
will produce a compile error because we are ignoring a potential error. We must handle it.// In the .Playing case of update() if (frame_count % 10 == 0) { snake.update() catch |err| { // This block runs if snake.update() returns an error. w4.trace("Error updating snake!"); // We can even trace the specific error w4.trace(@errorName(err)); game_state = .GameOver; }; if (snake.checkSelfCollision()) { // ... game over logic } }
catch |err| { ... }
: This is how you handle a potential error. Ifsnake.update()
fails, the code inside thecatch
block is executed. The|err|
part "captures" the error value into theerr
variable.@errorName(err)
: This built-in function converts an error value into its string name (e.g., "OutOfMemory"), which is great for debugging.
With our FixedBufferAllocator
, it's very unlikely we'll hit this error unless the snake fills the entire screen. But now you know the fundamental pattern for robust error handling in Zig, which is crucial for writing reliable software.
Your final main.zig
should look something like this after all the changes.
const std = @import("std");
const w4 = @import("wasm4.zig");
const Point = struct {
x: i32,
y: i32,
};
const Snake = struct {
body: std.ArrayList(Point),
direction: Point,
pub fn init(allocator: std.mem.Allocator) Snake {
var snake = Snake{
.body = std.ArrayList(Point).init(allocator),
.direction = Point{ .x = 1, .y = 0 },
};
snake.body.appendSlice(&.{
Point{ .x = 2, .y = 0 },
Point{ .x = 1, .y = 0 },
Point{ .x = 0, .y = 0 },
}) catch @panic("Failed to initialize snake body");
return snake;
}
pub fn deinit(self: *Snake) void {
self.body.deinit();
}
pub fn draw(self: *const Snake) void {
w4.DRAW_COLORS.* = 0x33;
for (self.body.items) |part| {
w4.rect(part.x * 8, part.y * 8, 8, 8);
}
const head = self.body.items[0];
w4.DRAW_COLORS.* = 0x44;
w4.rect(head.x * 8, head.y * 8, 8, 8);
}
// Note the '!' indicating a possible error return
pub fn update(self: *Snake) !void {
const old_head = self.body.items[0];
const new_head = Point{
.x = @mod(old_head.x + self.direction.x, 20),
.y = @mod(old_head.y + self.direction.y, 20),
};
var ate_fruit = false;
if (new_head.x == fruit.x and new_head.y == fruit.y) {
ate_fruit = true;
score += 10;
fruit.x = random.intRangeLessThan(i32, 0, 20);
fruit.y = random.intRangeLessThan(i32, 0, 20);
w4.tone(880, 10, 80, w4.TONE_PULSE1);
}
try self.body.insert(0, new_head);
if (!ate_fruit) {
_ = self.body.pop();
}
}
pub fn checkSelfCollision(self: *const Snake) bool {
const head = self.body.items[0];
for (self.body.items[1..]) |part| {
if (head.x == part.x and head.y == part.y) {
return true;
}
}
return false;
}
};
comptime {
var FRUIT_WIDTH: u32 = 8;
var FRUIT_HEIGHT: u32 = 8;
var FRUIT_FLAGS: u32 = w4.BLIT_2BPP;
}
const fruit_sprite = [16]u8{ 0x00,0xa0,0x02,0x00,0x0e,0xf0,0x36,0x5c,0xd6,0x57,0xd5,0x57,0x35,0x5c,0x0f,0xf0 };
var memory_buffer: [1600]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&memory_buffer);
const allocator = fba.allocator();
var prng = std.rand.DefaultPrng.init(0);
const random = prng.random();
var snake = Snake.init(allocator);
var fruit = Point{ .x = 10, .y = 10 };
var frame_count: u32 = 0;
var score: u32 = 0;
const GameState = enum { Title, Playing, GameOver };
var game_state: GameState = .Title;
fn input() void {
const gamepad = w4.GAMEPAD1.*;
if (gamepad & w4.BUTTON_UP != 0 and snake.direction.y == 0) {
snake.direction = Point{ .x = 0, .y = -1 };
}
if (gamepad & w4.BUTTON_DOWN != 0 and snake.direction.y == 0) {
snake.direction = Point{ .x = 0, .y = 1 };
}
if (gamepad & w4.BUTTON_LEFT != 0 and snake.direction.x == 0) {
snake.direction = Point{ .x = -1, .y = 0 };
}
if (gamepad & w4.BUTTON_RIGHT != 0 and snake.direction.x == 0) {
snake.direction = Point{ .x = 1, .y = 0 };
}
}
export fn start() void {
w4.PALETTE.* = .{
0xfbf7f3,
0xe5b083,
0x426e5d,
0x20283d,
};
fruit.x = random.intRangeLessThan(i32, 0, 20);
fruit.y = random.intRangeLessThan(i32, 0, 20);
}
export fn update() void {
switch (game_state) {
.Title => {
w4.DRAW_COLORS.* = 0x4321;
w4.text("ZIG SNAKE", 48, 60);
w4.text("Press X to Start", 28, 80);
const gamepad = w4.GAMEPAD1.*;
if (gamepad & w4.BUTTON_1 != 0) {
game_state = .Playing;
}
},
.Playing => {
input();
frame_count += 1;
if (frame_count % 10 == 0) {
snake.update() catch |err| {
w4.trace("Snake update failed!");
w4.trace(@errorName(err));
game_state = .GameOver;
};
if (snake.checkSelfCollision()) {
game_state = .GameOver;
w4.tone((110 << 16) | 220, 30, 100, w4.TONE_NOISE);
}
}
w4.DRAW_COLORS.* = 0x11;
w4.rect(0, 0, 160, 160);
snake.draw();
w4.DRAW_COLORS.* = 0x4321;
w4.blit(&fruit_sprite, fruit.x * 8, fruit.y * 8, FRUIT_WIDTH, FRUIT_HEIGHT, FRUIT_FLAGS);
w4.DRAW_COLORS.* = 0x43;
var score_buffer: [32]u8 = undefined;
const score_text = std.fmt.bufPrint(&score_buffer, "Score: {d}", .{score}) catch "Error";
w4.text(score_text, 5, 5);
},
.GameOver => {
w4.text("GAME OVER", 52, 70);
w4.text("Press X to Restart", 20, 80);
const gamepad = w4.GAMEPAD1.*;
if (gamepad & w4.BUTTON_1 != 0) {
snake.deinit();
snake = Snake.init(allocator);
score = 0;
game_state = .Title;
}
},
}
}
You did it! You've taken a basic game and added essential features, all while learning some of the most powerful and unique aspects of the Zig programming language. You now have a solid foundation to build your own games and explore more complex Zig projects.
For WebAssembly targets in Zig 0.14.1, you need to use the new target specification syntax. Here's how to fix it:
const std = @import("std");
pub fn build(b: *std.Build) !void {
const exe = b.addExecutable(.{
.name = "cart",
.root_source_file = b.path("src/main.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
}),
.optimize = b.standardOptimizeOption(.{}),
});
exe.entry = .disabled;
exe.rdynamic = true;
exe.import_memory = true;
exe.initial_memory = 65536;
exe.max_memory = 65536;
exe.stack_size = 14752;
b.installArtifact(exe);
}