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
w4CLI: 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 snakeThis 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 watchYour 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.zigwhich contains the WASM-4 API bindings. We'll create this file next.@importis Zig's way of including other source files.export fn update() void { ... }: This defines and exports a function namedupdate. Theexportkeyword 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 4u32s".pub: This keyword makes thePALETTEandDRAW_COLORSconstants public, somain.zigcan 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_COLORSpointer 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 Points) 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.*constmeans 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.itemsis the slice ofPoints from ourArrayList.partwill hold eachPointfor 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: Theupdatemethod 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 thegamepadbyte.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 ofTypein 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) { ... }: Aswitchstatement 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
.GameOverstate, we check for a button press to reset the game by re-initializing the snake. - We need the
w4.tracefunction, so addpub extern fn trace(str: [*:0]const u8) void;towasm4.zig. The[*:0]const u8type 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=ReleaseSmallThis 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.htmlYou 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
comptimefor compile-time code execution, or learn about error handling withtryandcatch. - 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.fmtmodule 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
tryandcatch. - 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 screenTo 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.undefinedmeans 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":bufPrintreturns an error if the buffer is too small. We usecatchto 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.fmtmodule 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
tryandcatch. - 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 screenTo 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.undefinedmeans 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":bufPrintreturns an error if the buffer is too small. We usecatchto 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.pngin your project's root directory. - Run the
w4tool 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.updateto 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 @panicwith thetrykeyword.tryis a shortcut: if the expression returns an error,tryimmediately 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
updatefunction, 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 thecatchblock is executed. The|err|part "captures" the error value into theerrvariable.@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);
}
Thanks for making this!
One question: i'm new to Zig and using version 0.15.1, and
ArrayLists are now unmanaged by default. This changes the snake `init(). To fix this, would you add the allocator as a field to the Snake struct or use an explicit managed ArrayList?