Skip to content

Instantly share code, notes, and snippets.

@akhenakh
Created June 13, 2025 12:54
Show Gist options
  • Save akhenakh/5d66a200d9846096ec7febb174682961 to your computer and use it in GitHub Desktop.
Save akhenakh/5d66a200d9846096ec7febb174682961 to your computer and use it in GitHub Desktop.
learning Zig using Wasm-4 fantasy console

Learning Zig with WASM-4

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.


Learn Zig by Making a Snake Game for WASM-4

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!


Chapter 1: Project Setup and "Hello, World!"

First, we need to set up our development environment and create a new WASM-4 project.

Prerequisites

  1. Install Zig: Follow the official instructions at ziglang.org/learn/getting-started/. A recent version (0.11.0 or newer) is recommended.
  2. 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.

Creating the Project

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 Script: build.zig

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
}

Running Your First Cartridge

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.

Understanding src/main.zig

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 named wasm4.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 named update. The export 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.


Chapter 2: The WASM-4 API and Drawing Rectangles

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.

Memory-Mapped Registers

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 4 u32s".
  • pub: This keyword makes the PALETTE and DRAW_COLORS constants public, so main.zig can access them.

Imported Functions

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.

Putting it to Use

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 the DRAW_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!


Chapter 3: Data Structures for the Snake

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.

The Point Struct

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 Struct

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:

  1. The Standard Library (std): We @import("std") to get access to useful data structures.
  2. std.ArrayList: A dynamic array. It needs an allocator to manage its memory.

Memory Management in WASM-4

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.


Chapter 4: Drawing the Snake

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 conventionally self. *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 of Points from our ArrayList. part will hold each Point 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!


Chapter 5: Moving the Snake

A snake needs to move. We'll create an update method for the Snake struct to handle its movement logic.

The logic is simple:

  1. Add a new head in the current direction.
  2. 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: The update 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.

Slowing it Down

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).

Chapter 6: Handling User Input

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 the gamepad 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!


Chapter 7: Eating and Growing

A snake's gotta eat. Let's add a fruit and collision detection.

Random Fruit

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 of Type in the range [min, max).

Drawing and Collision

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.


Chapter 8: Game Over and Final Touches

The last piece of the puzzle is the "Game Over" condition: the snake colliding with its own body.

Self-Collision

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).

Game State

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) { ... }: A switch statement lets us execute different code based on the value of game_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 add pub extern fn trace(str: [*:0]const u8) void; to wasm4.zig. The [*:0]const u8 type is a C-style null-terminated string.

Congratulations! You have a complete, working Snake game written in Zig.


Chapter 9: Publishing Your Game

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!

What's Next?

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 with try and catch.
  • 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.


Learn Zig Part 2: Polishing Your Snake Game

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 and catch.
  • Sprites: We'll replace our simple rectangle fruit with a proper sprite image.

Let's begin!


Chapter 10: A Proper Title Screen

Every game needs a title screen. This requires us to expand our concept of the "game state" and learn how to draw text.

Expanding the Game State

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

Drawing Text

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.

The Title Screen Logic

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.


Chapter 11: Keeping Score with std.fmt

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.

Tracking the Score

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)
}

Displaying the Score

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:

  1. A mutable byte slice (our buffer) to write the formatted string into.
  2. A compile-time known format string.
  3. 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 use catch 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.


Learn Zig Part 2: Polishing Your Snake Game

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 and catch.
  • Sprites: We'll replace our simple rectangle fruit with a proper sprite image.

Let's begin!


Chapter 10: A Proper Title Screen

Every game needs a title screen. This requires us to expand our concept of the "game state" and learn how to draw text.

Expanding the Game State

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

Drawing Text

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.

The Title Screen Logic

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.


Chapter 11: Keeping Score with std.fmt

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.

Tracking the Score

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)
}

Displaying the Score

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:

  1. A mutable byte slice (our buffer) to write the formatted string into.
  2. A compile-time known format string.
  3. 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 use catch 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!


Chapter 12: Bleeps and Bloops (Sound Effects)

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!


Chapter 13: Sprites and comptime

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.

Creating the Sprite

  1. 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.
  2. Run the w4 tool from your terminal:
    w4 png2src --zig fruit.png
  3. 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 };

Using comptime

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!


Chapter 14: Robust Code with Error Handling

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.

  1. 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
  2. Use try: Replace the catch @panic with the try 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();
    }
  3. Handle the error at the call site: Now, back in the main update function, the call to snake.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. If snake.update() fails, the code inside the catch block is executed. The |err| part "captures" the error value into the err 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.

The Complete Game

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;
            }
        },
    }
}

Conclusion

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.

Notes compiling with recent zig:

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);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment