Skip to content

Instantly share code, notes, and snippets.

@peterhellberg
Last active March 16, 2026 22:12
Show Gist options
  • Select an option

  • Save peterhellberg/b331a0330325084ffcb3ab1e48302061 to your computer and use it in GitHub Desktop.

Select an option

Save peterhellberg/b331a0330325084ffcb3ab1e48302061 to your computer and use it in GitHub Desktop.
WASMCarts in Zig

WASMCarts in Zig

I just found out about the in development fantasy console

https://github.com/pgattic/wasm-experiment

Since I'm on Pop_OS! which is currently based on 24.04 I need to install a newer version of cmake than what is in the default repos:

I installed SDL3 from source, such as what is described in this article:

wasmcarts-buildtool

This tool is written in Rust, so I just had to cargo build --release and then symlink the resulting binary to somewhere in my $PATH

wasmcarts (Linux engine)

$ export WASM3_SOURCE=/home/peter/Code/GitHub/wasm3/wasm3
$ CC="zig cc -fno-sanitize=undefined" cmake -S . -B build/linux -DCMAKE_C_FLAGS="-std=c23" -DCMAKE_C_FLAGS="-D_GNU_SOURCE"

Example cart in Zig

WASMCarts.toml

[package]
name = "wc-zig"
author = "Peter Hellberg"
version = "0.0.1"

[build.code]
command = ["zig", "build"]
output = "zig-out/bin/cart.wasm"

[build.assets]
dir = "assets"
sprite_tiles = "spr.4bpp"
background_tiles = "bg.4bpp"
background_map = "bg.map"

build.zig

const std = @import("std");

pub fn build(b: *std.Build) !void {
    const mod = b.createModule(.{
        .root_source_file = b.path("src/cart.zig"),
        .target = b.resolveTargetQuery(.{
            .cpu_arch = .wasm32,
            .os_tag = .wasi,
        }),
        .optimize = .ReleaseSmall,
    });

    mod.export_symbol_names = &[_][]const u8{
        "setup",
        "update",
    };

    const exe = b.addExecutable(.{
        .name = "cart",
        .root_module = mod,
    });

    exe.entry = .disabled;
    exe.import_memory = false;

    b.installArtifact(exe);
}

src/wc.zig

pub extern "env" fn _rand() u32;
pub extern "env" fn _clearScreen(c: u8) void;
pub extern "env" fn _pSet(x: i32, y: i32, c: u8) void;
pub extern "env" fn _rect(x: i32, y: i32, w: u32, h: u32, c: u8) void;
pub extern "env" fn _rectFill(x: i32, y: i32, w: u32, h: u32, c: u8) void;
pub extern "env" fn _sprite(x: i32, y: i32, id: u8) void;
pub extern "env" fn _btn(btn: u8) bool;
pub extern "env" fn _btnP(btn: u8) bool;
pub extern "env" fn _printLnDbg(ptr: usize) void;

pub fn rand() u32 {
    return _rand();
}

pub fn clearScreen(c: u8) void {
    _clearScreen(c);
}

pub fn pSet(x: i32, y: i32, c: u8) void {
    _pSet(x, y, c);
}

pub fn rect(x: i32, y: i32, w: u32, h: u32, c: u8) void {
    _rect(x, y, w, h, c);
}

pub fn rectFill(x: i32, y: i32, w: u32, h: u32, c: u8) void {
    _rectFill(x, y, w, h, c);
}

pub fn sprite(x: i32, y: i32, id: u8) void {
    _sprite(x, y, id);
}

pub const Button = enum(u8) {
    Left = 0,
    Right = 1,
    Up = 2,
    Down = 3,
    A = 4,
    B = 5,
    X = 6,
    Y = 7,
    L = 8,
    R = 9,
    Start = 10,
    Select = 11,
};

pub fn btn(button: Button) bool {
    return _btn(@intFromEnum(button));
}

pub fn btnP(button: Button) bool {
    return _btnP(@intFromEnum(button));
}

pub fn print(str: []const u8) void {
    _printLnDbg(@intFromPtr(str.ptr));
}

src/cart.zig

const wc = @import("wc.zig");

export fn setup() void {
    wc.print("Hello from Zig!");
}

export fn update() void {
    const w = 24;
    const h = 24;

    wc.clearScreen(0);

    wc.rectFill(12, 12, w, h, 1);
    wc.rectFill(24, 24, w, h, 2);
    wc.rectFill(36, 36, w, h, 3);
    wc.rectFill(48, 48, w, h, 4);
    wc.rectFill(60, 60, w, h, 5);
}
@peterhellberg
Copy link
Copy Markdown
Author

PICO-8 palette

@peterhellberg
Copy link
Copy Markdown
Author

Updated src/wc.zig

const std = @import("std");

/// `env` provides access to the fantasy console's imported functions.
///
/// These functions are imported from the Wasm environment module `"env"`,
/// giving your Zig code direct access to graphics, input, text, and debugging.
pub const env = struct {
    /// Returns a random 32-bit unsigned integer.
    pub extern "env" fn _rand() u32;

    /// Clears the screen with color `c`.
    pub extern "env" fn _clearScreen(c: u8) void;

    /// Sets the pixel at `(x, y)` to color `c`.
    pub extern "env" fn _pSet(x: i32, y: i32, c: u8) void;

    /// Draws an outline rectangle at `(x, y)` with width `w`, height `h`, and color `c`.
    pub extern "env" fn _rect(x: i32, y: i32, w: u32, h: u32, c: u8) void;

    /// Draws a filled rectangle at `(x, y)` with width `w`, height `h`, and color `c`.
    pub extern "env" fn _rectFill(x: i32, y: i32, w: u32, h: u32, c: u8) void;

    /// Draws the sprite with ID `id` at `(x, y)`.
    pub extern "env" fn _sprite(x: i32, y: i32, id: u8) void;

    /// Displays the character with code `char_code` at `(x, y)`.
    pub extern "env" fn _showChar(x: i32, y: i32, char_code: u8) void;

    /// Prints a null-terminated string at `(x, y)`. `ptr` points to the string in memory.
    pub extern "env" fn _print(x: i32, y: i32, ptr: usize) void;

    /// Returns `true` if the given button is currently pressed.
    pub extern "env" fn _btn(btn: u8) bool;

    /// Returns `true` if the given button was pressed this frame.
    pub extern "env" fn _btnP(btn: u8) bool;

    /// Prints a null-terminated string at memory location `ptr` to the debug console.
    pub extern "env" fn _printLnDbg(ptr: usize) void;
};

// ┌───────────────────────────────────────────────────────────────────────────┐
// │                                                                           │
// │ Platform Constants                                                        │
// │                                                                           │
// └───────────────────────────────────────────────────────────────────────────┘

/// Width of the screen.
pub const SCREEN_WIDTH = 240;

/// Height of the screen.
pub const SCREEN_HEIGHT = 160;

// ┌───────────────────────────────────────────────────────────────────────────┐
// │                                                                           │
// │ Drawing Functions                                                         │
// │                                                                           │
// └───────────────────────────────────────────────────────────────────────────┘

/// Clears the screen with color `c`.
pub fn cls(c: u8) void {
    env._clearScreen(c);
}

/// Draw a pixel with the specified x and y position and color index.
pub fn pset(x: i32, y: i32, c: u8) void {
    env._pSet(x, y, c);
}

/// Draws an outline rectangle at `(x, y)` with width `w`, height `h`, and color `c`.
pub fn rect(x: i32, y: i32, w: u32, h: u32, c: u8) void {
    env._rect(x, y, w, h, c);
}

/// Draws a filled rectangle at `(x, y)` with width `w`, height `h`, and color `c`.
pub fn rectFill(x: i32, y: i32, w: u32, h: u32, c: u8) void {
    env._rectFill(x, y, w, h, c);
}

/// Draws the sprite with ID `id` at `(x, y)`.
pub fn spr(x: i32, y: i32, id: u8) void {
    env._sprite(x, y, id);
}

/// Displays the character with code `char_code` at `(x, y)`.
pub fn chr(x: i32, y: i32, char_code: u8) void {
    env._showChar(x, y, char_code);
}

/// Display the string at `(x, y)`
pub fn print(x: i32, y: i32, str: []const u8) void {
    env._print(x, y, @intFromPtr(str.ptr));
}

// ┌───────────────────────────────────────────────────────────────────────────┐
// │                                                                           │
// │ Input Functions                                                           │
// │                                                                           │
// └───────────────────────────────────────────────────────────────────────────┘

/// The various buttons supported by WASMCart.
///
/// Used by functions like `btn()` and `btnp()`
pub const Button = enum(u8) {
    Left = 0,
    Right = 1,
    Up = 2,
    Down = 3,
    A = 4,
    B = 5,
    X = 6,
    Y = 7,
    L = 8,
    R = 9,
    Start = 10,
    Select = 11,
};

/// Returns `true` if the given button is currently pressed.
pub fn btn(button: Button) bool {
    return env._btn(@intFromEnum(button));
}

/// Returns `true` if the given button was pressed this frame.
pub fn btnp(button: Button) bool {
    return env._btnP(@intFromEnum(button));
}

// ┌───────────────────────────────────────────────────────────────────────────┐
// │                                                                           │
// │ Other Functions                                                           │
// │                                                                           │
// └───────────────────────────────────────────────────────────────────────────┘

/// Generate a random 32-bit integer.
///
/// Note that the randomness of the number may depend on engine-side implementation details. If you
/// want the random numbers to follow a certain sequence every time the game is started or be based
/// on a seed, you will have to create your own generator.
///
pub fn rand() u32 {
    return env._rand();
}

/// Log to the debug console.
pub fn log(str: []const u8) void {
    env._printLnDbg(@intFromPtr(str.ptr));
}

/// Log a formatted string to the debug console.
pub fn logf(comptime fmt: []const u8, args: anytype) void {
    var buf: [64]u8 = [_]u8{0} ** 64;

    _ = std.fmt.bufPrint(&buf, fmt, args) catch
        @panic("logf: overflow");

    env._printLnDbg(@intFromPtr(&buf[0]));
}

/// Display a formatted string at `(x, y)`
pub fn printf(x: i32, y: i32, comptime fmt: []const u8, args: anytype) void {
    var buf: [64]u8 = [_]u8{0} ** 64;

    _ = std.fmt.bufPrint(&buf, fmt, args) catch
        @panic("printf: overflow");

    env._print(x, y, @intFromPtr(&buf[0]));
}

@peterhellberg
Copy link
Copy Markdown
Author

peterhellberg commented Mar 14, 2026

Updated src/cart.zig

const wc = @import("wc.zig");

const State = struct {
    score: u32 = 0,
    pos: @Vector(2, i32) = .{ 0, 0 },
};

var state = State{};

export fn setup() void {
    wc.log("Hello from Zig!");
}

export fn update() void {
    // Update the score
    if (wc.btnp(.X)) state.score +|= 1;
    if (wc.btnp(.Y)) state.score -|= 1;

    // Update the position
    if (wc.btn(.Left)) state.pos[0] -|= 1;
    if (wc.btn(.Right)) state.pos[0] +|= 1;
    if (wc.btn(.Up)) state.pos[1] -|= 1;
    if (wc.btn(.Down)) state.pos[1] +|= 1;

    if (wc.btnp(.X) or wc.btnp(.Y) or
        wc.btnp(.Left) or wc.btnp(.Right) or
        wc.btnp(.Up) or wc.btnp(.Down))
    {
        wc.logf("State: {}", .{state});
    }

    draw();
}

fn draw() void {
    // Clear the screen with black
    wc.cls(0);

    // Draw colored dots on the background
    for (0..wc.SCREEN_HEIGHT / 16) |uy| {
        for (0..wc.SCREEN_WIDTH / 16) |ux| {
            const x: i32 = @intCast(ux * 16);
            const y: i32 = @intCast(uy * 16);
            wc.pset(x + 8, y + 8, @intCast(1 + @mod((x ^ y), 15)));
        }
    }

    // Yellow rectangle
    wc.rect(2, 2, 8, 8, 10);

    // Draw a few pixels
    wc.pset(4, 4, 11);
    wc.pset(5, 5, 12);
    wc.pset(6, 6, 13);
    wc.pset(7, 7, 14);

    const w = 24;
    const h = 24;

    // A few rectangles
    wc.rectFill(12, 12, w, h, 1);
    wc.rect(24, 24, w, h, 2);
    wc.rectFill(36, 36, w, h, 3);
    wc.rect(48, 48, w, h, 4);
    wc.rectFill(60, 60, w, h, 5);

    // Rectangles that only show up when A or B is pressed
    if (wc.btn(.A)) wc.rectFill(72, 72, w, h, 6);
    if (wc.btn(.B)) wc.rectFill(84, 84, w, h, 7);

    // Log button presses
    if (wc.btnp(.Left)) wc.log("Left");
    if (wc.btnp(.Right)) wc.log("Right");
    if (wc.btnp(.Up)) wc.log("Up");
    if (wc.btnp(.Down)) wc.log("Down");
    if (wc.btnp(.A)) wc.log("A");
    if (wc.btnp(.B)) wc.log("B");
    if (wc.btnp(.X)) wc.log("X");
    if (wc.btnp(.Y)) wc.log("Y");
    if (wc.btnp(.L)) wc.log("L");
    if (wc.btnp(.R)) wc.log("R");
    if (wc.btnp(.Start)) wc.log("Start");
    if (wc.btnp(.Select)) wc.log("Select");

    // Display some text
    wc.rectFill(12, 110, 208, 28, 0);
    wc.print(12, 110, "1234567890+!#%&/([{}])=?@$");
    wc.print(12, 120, "abcdefghijklmnopqrstuvwxyz");
    wc.print(12, 130, "ABCDEFGHIJKLMNOPQRSTUVWXYZ");

    // Display a single character
    wc.chr(12, 145, '#');

    // Draw some sprites
    wc.spr(90, 10, 0);
    wc.spr(90, 20, 1);
    wc.spr(90, 30, 2);
    wc.spr(90, 40, 3);
    wc.spr(90, 50, 4);
    wc.spr(90, 60, 5);

    wc.printf(122, 6, "Score: {}", .{state.score});

    // Draw a sprite at the current position
    wc.spr(state.pos[0], state.pos[1], 0);
}
Screenshot_2026-03-14_20-47-50

@peterhellberg
Copy link
Copy Markdown
Author

peterhellberg commented Mar 16, 2026

Basic conversion of 4bpp➤png and png➤4bpp

https://gist.github.com/peterhellberg/5f881735c6e91eadeefd8e0a7e6b3bab

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment