DEV Community

Cover image for WebAssembly With Zig, Pt. II
Steven L
Steven L

Posted on

WebAssembly With Zig, Pt. II

Hello everyone, and welcome back to a longer-form series I am (attempting) to write. It has been a few months, but I am here again putting together the next iteration of this series.

This will be a very long, but hopefully reasonably fun dive into more Zig and WebAssembly and seeing what we can do this time around. Grab a drink or snack and let's start digging in.

What We Know So Far

From the last article, I covered the basics of what WebAssembly (or WASM) can do for you. In short, it's a specialized engine inside a browser that we can access by feeding a stream of bytes, and the browser interprets packets of data into instructions to create virtual machine you can interact with. The advantages are pretty clear; WASM is fast, performant, and easy to target for compilers. If you can use WASM, you should try it.

Implementing said things, however, can be tricky, and the community is kind of spread all over the place. Rust is popular, but involves a lot of crates and boilerplate code being strapped on. C/C++ is an easy way of targeting WASM using Emscripten, and I'm sure it works well, but I feel it fits in the same place as Rust where a lot needs to be strapped on to bring you up to production speed.

The Zig approach is what I am mostly interested in, as the Zig compiler includes shims for targeting WebAssembly. Since Zig can be used as a compiler for C/C++, this means you could skip the Emscripten part entirely and incorporate a build.zig file into your C/C++ project, allowing you to mix C/C++ libraries into a Zig project targeting a WebAssembly backend fairly easily.

I have a very large interest in video game entertainment as well as game development. Games make people happy, games are fun to share with friends, and overall, games are a great way of pushing the envelope for computer science in many categories dealing with visuals, audio, computational geometry, artificial intelligence, and so much more. My goal as such is to get somewhere close to a game that we could perhaps "play" it.

Where We Last Were

We've seen how it's possible to load a WASM bytefile into a JavaScript-powered page. However, in the time since my last article, I have been working on a repository called zigtoys where I deploy my tests and upload the WASM files. You can download this and try out some examples for yourself, but for the most part I have two demos online you can see for yourself right now.

Mandelbrot is a little simpler than Conway's, because the Game of Life simulator does a lot of grid-based checks and makes use of a statically-defined "world". This idea of a game world is what we're going to need to focus on for a bit to start working on a game.

In both of these apps' folders we have a JS file to begin the WASM loading process and bind function names to Zig functions. This way we can use Zig from inside a JavaScript program, offloading difficult and long computations to the WASM engine instead of relying on the JavaScript engine.

However, we haven't gone backwards much, have we? I haven't really touched upon that much, because well... For better or worse, there are some parts I'm still not sure about on that front. JavaScript functions typically use things like i32 or dynamically-allocated strings, so it's hard for me to gauge how that can be used within Zig. It's slightly easier for me to judge how things interact when I focus on Zig->JavaScript instead of JavaScript->Zig.

In life.zig, a game world is explicitly declared, and that is kept throughout the lifetime of the web page being opened. This makes it very simple; the game world is static, but mutable, so this is what we could use for a jumping-off point to start creating small games in Zig for WASM purposes.

The goal would be to create a game that can update it's internal state with a simple function, and expose the data to JavaScript so it can be presented easily. We might have to mix and match some JavaScript here and there, but for a trade-off of performance and using Zig on the web, I'll take it for now.

Building a World

Building a world is one thing, it's no different than Conway's Game of Life. Declare an array and fill it with numbers representing the different entities you wish it to have. In Conway's case, it was simply some cells describing a square, and whether that square was dead or alive.



const State = struct{
    cells: [10*10]u8,
};

var WORLD = State{
    .cells = undefined, // auto-zero the array
};


Enter fullscreen mode Exit fullscreen mode

Here I am creating a new struct called State which we will want to contain information about our world inside of. Then afterwards, I declare a new variable called WORLD, which we will update and occasionally ask for information about the world. I use uppercase here to denote it's importance, but you can use what you wish.

The problem is it doesn't seem like storing u8's will be a great idea, and we should maybe use an enum type to describe the types of actors in our little world. Let's shift that over to an enum now and create a micro-world with which we can move around in.



const Entity = enum{
    Empty,
    Player,
    Wall,
};

const State = struct{
    cells: [W_SIZE]Entity,
};

var WORLD = State{
    .cells = undefined, // auto-zero the array
};


Enter fullscreen mode Exit fullscreen mode

The new Entity enumeration describes what kind of actors our world will have. This is a pretty basic approach, but for a tiny sample game, this is fine for me. Type-aliasing this info away from a raw number is better and facilitates better naming conventions in code, ie. currEntity == Entity.Player versus currEntity == 5. Less confusion the better.

We're not quite out of the woods yet, as it might be a good idea to define constants at the top of our file here to indicate a maximum world size and obtain information for bounds checks.



const WIDTH: u32 = 10;
const HEIGHT: u32 = 10;
const W_SIZE: u32 = WIDTH * HEIGHT;

const Entity = enum{
    Empty,
    Player,
    Wall,
};

const State = struct{
    cells: [W_SIZE]Entity,
};

var WORLD = State{
    .cells = undefined,
};


Enter fullscreen mode Exit fullscreen mode

Like in my Game of Life sample, it's a good idea to use some constants to customize your world so you can adjust upwards or downwards as you see fit, or design some crazy stuff. Maybe you can parameterize it later so your world is resizable on the fly instead of statically like this, but that might be a different feature for later.

We haven't defined any functions still, so compiling this would lead to the most bare-bones WASM file imaginable. We're going to start working on a function called update(), which updates the state of the world by mutating the data it contains. We still need some helper functions to help us communicate with the cells of the world itself, since it is a linear 1-dimensional array. It might be convenient for some calculations from a 2D space using some helper functions to do the math.



fn calc_pos(x: u8, y: u8) usize {
    return x + (y * HEIGHT);
}

export fn get_pos(x: u8, y: u8) Entity {
    var index = calc_pos(x, y);
    if (index < W_SIZE)
        return WORLD.cells[index];
    return .Empty;
}

export fn set_pos(x: u8, y: u8, v: Entity) bool {
    var index = calc_pos(x, y);
    if (index < W_SIZE) {
        WORLD.cells[index] = v;
        return true;
    }
    return false;
}


Enter fullscreen mode Exit fullscreen mode

The calc_pos(x,y) function will help us to calculate an index from two Cartesian co-ordinates, while get_pos(x,y) and set_pos(x,y,v) are the general interface for changing the game. All the updates should be done through those two functions when it comes to changing the cells.

It might help if we have a thing in place to set the world to some initial state, so maybe let's define an init() function which will give us some default world we can toy around in.



export fn init() void {
    _ = set_pos(4, 4, Cell.Wall);
    _ = set_pos(4, 3, Cell.Wall);
    _ = set_pos(4, 5, Cell.Wall);
    _ = set_pos(3, 4, Cell.Wall);
    _ = set_pos(5, 4, Cell.Wall);
    return;
}


Enter fullscreen mode Exit fullscreen mode

Granted, since I want set_pos to return a Boolean of whether or not it could place an entity on the grid, we don't really need it in our tiny example here, so I use the _ = <expr> syntax to discard the result and ignore whatever gets thrown back. Those fixed numbers work for a 10x10 grid, but since I'm not writing them with a variable in mind, it could fail if your grid was too small, or look strange on a board with odd dimensions.

Displaying Our World

Next let's focus back on the JavaScript aspect, and go back and create a file that loads our WASM and binds our functions here and will actually start rendering our game world. We'll use a basic grid on a <canvas> element to portray our little game here.

First, let's look at how we originally loaded our WASM. This is done on a local HTTP server spun up with python -m http.server, which is frankly the fastest way I can think of to get something going for this. If you don't have Python, maybe try nodejs or a Docker HTTP server runtime of your choice.



request = new XMLHttpRequest();
request.open('GET', 'game.wasm');
request.responseType = 'arraybuffer';
request.send();

request.onload = function() {
    var bytes = request.response;
    WebAssembly.instantiate(bytes, {
        env: {}
    }).then(result => {
        // store references to Zig functions
        get_pos = result.instance.export.get_pos;
        // repeat etc ...
    });
};


Enter fullscreen mode Exit fullscreen mode

Curiously enough, I never really looked at what is really inside the result.instance.exports object we seem to get. Let's try now.

Build your Zig first into a WASM (I keep forgetting what this command is so I'm really doing this for myself).



$ zig build-lib game.zig -target wasm32-freestanding -dynamic -O ReleaseFast


Enter fullscreen mode Exit fullscreen mode

This outputs a game.wasm file (assuming you also named your file game.zig). Then we load it in our browser by pointing our browser to localhost:8000 (or wherever your local HTTP server is bound), and browsing to our web page, which we haven't made yet.



<!doctype html>
<html>
  <head>
    <title>Game Demo</title>
  </head>
  <body>
    <h3>Game Demo</h3>
    <canvas id="game_canvas" width="100" height="100">
      Browser does not support HTML5 canvas element
    </canvas>
  </body>
  <script src="game.js"></script>
</html>


Enter fullscreen mode Exit fullscreen mode

After that, let's load into our web page with the local HTTP server running, and wedge in a tiny console.log statement on the results object to see what we're working with. The output looks like this:



Object { memory: WebAssembly.Memory, get_pos: 0(), set_pos: 1(), init: 2(), update: 3() }
get_pos: function 0()
init: function 2()
memory: WebAssembly.Memory { buffer: ArrayBuffer }
set_pos: function 1()
update: function 3()


Enter fullscreen mode Exit fullscreen mode

Interestingly enough, it seems like my way of manually binding variables to these functions may have been redundant as we have that mapping already created for us. Now we can reduce some cognitive load a bit by assigning this to a namespace we can recognize. Before the WASM loading, let's create an object with some properties filled out.



var Game = {
    'init': null,
    'get_pos': null,
    'set_pos': null,
    'update': null,
    'loaded': false;
    'running': false,
};


Enter fullscreen mode Exit fullscreen mode

If the WASM fails to load for some reason, we can use this as a means of declaring "empty" functions that will simply error out instead of leaving us with dead, undefined function errors. Maybe we can change this later to actually be functions that display better errors, but for right now I think this is okay.

Then all we do (during the WASM load phase) is bind this variable to the WASM export.



Game = result.instance.exports;


Enter fullscreen mode Exit fullscreen mode

This might pose risks as the memory binding is tied in as well. However, it seems like in the WASM loading process, an ArrayBuffer given to the memory is not shared and as such is completely locked down. This might not be full-proof, and I'll come back if there's something leaky about this.

Now to test it, we can call get_pos() and set_pos() on it.



>>> Game.set_pos(4, 4, 2)
1 // indicator of it worked
>>> Game.get_pos(4, 4)
2 // the value we set!


Enter fullscreen mode Exit fullscreen mode

This shows us that it's actually working and yields a Boolean indicative of whether it worked or not. Awesome stuff so far.

We need a way of displaying our game world, so let's try to do that quickly with a little canvas JS.



var canvas = window.document.getElementById("game_canvas");
var ctx = canvas.getContext('2d');

var main = function() {
    console.log("Main function started");

    var loop = function() {
    ctx.fillStyle = "white"; // clear the canvas
    ctx.fillRect(0, 0, 100, 100);
    for(var x = 0; x < 10; x++) {
        for(var y = 0; y < 10; y++) {
        var cell = Game.get_pos(x, y);
        if (cell == 1)
            ctx.fillStyle = "red";
        else if (cell == 2)
            ctx.fillStyle = "grey";
        else
            ctx.fillStyle = "white";
        ctx.fillRect(x*10, y*10, (x*10)+10, (y*10)+10);
        }
    }

        // loop to next frame
    if (Game.running)
        window.requestAnimationFrame(loop);
    };
    loop();
};


Enter fullscreen mode Exit fullscreen mode

This will render a small game world where walls are grey, empty space is white, and a mysterious player is red. However, our player is nowhere to be found, because we never defined how he would move just yet. That will come later. If everything is done correctly up to here, you will see a canvas with this showing.

A game world with a small grey cross in the middle. I have a dark mode setting so the white game board stands out.

I am going to make a quick change to our WASM loader to change it from .instantiate to .instantiateStreaming, as mentioned by @rofrol in my last post (thanks!!). This makes use of an asynchronous promise via the fetch() function assuming your browser supports it, otherwise you will have to go back to the old XMLHttpRequest way. The WASM code will look like this now.



window.document.body.onload = function() {
    var env = { env: {} };
    WebAssembly.instantiateStreaming(fetch("game.wasm"), env).then(result => {
        console.log("Loaded the WASM!");
        Game = result.instance.exports;
        Game.loaded = true;
        Game.init(); // initialize world
        main(); // begin
    });
};


Enter fullscreen mode Exit fullscreen mode

With that, it's time we move onto the next section of how we actually interact with this as a player.

Playing The Game

There isn't really a clear-cut objective here. We have a supposed Player that walks around this small grid of empty walls, with no real gameplay. Kind of sucks, right? Not much of a game.

We can gamify it with a few changes in mind.

  1. make it so we can walk the player entity around
  2. have a goal in mind, perhaps a destination
  3. add a score, make it fun
  4. randomization! or levels.

The first is to add controls, so we can move a player around. The second is adding a new entity called Goal where the player must step on it to win. The third is adding a tracker to the game state. The fourth is... Well, that's something else entirely I would say, and maybe a little complex a topic for right now.

The first goal in mind is to add the concept of a Player into the game. Naturally by having a cell entity be the Player is the first thing in our mind, but we need a little help tracking his position. Each time the player moves, his index in the array gets updated, so anytime you want to move the player, you have to find him again.

Naturally, you can add a variable into the world state, or maybe two, just to make calculations less annoying. The horizontal and vertical position can be stored, and this will help us to calculate his movements.



const State = struct{
    playerx: u8,
    playery: u8,
    cells: [W_SIZE]Entity,
};


Enter fullscreen mode Exit fullscreen mode

We store playerx and playery as u8 types for now, because our grid is quite literally a 10x10 grid. We cannot go smaller with Zig custom bit-types like u4, because the C translation needs C conformant types, and the lowest is one byte, or eight bits (u8 in Zig).

Now, any time update() is called (which it isn't being called ever), we can update our world by adjusting the playerx and playery attributes reflecting our changes. So now we can map a value to reflect moving.



export fn update(dir: u8) void {
    if (dir == 0)
        WORLD.playery -= 1;
    if (dir == 1)
        WORLD.playery += 1;
    if (dir == 2)
        WORLD.playerx -= 1;
    if (dir == 3)
        WORLD.playerx += 1;
    return;
}


Enter fullscreen mode Exit fullscreen mode

Okay that seems fine... Except wait, this is probably better reserved for an enum type so Zig can check it much easier (and safer). Let's move the direction into it's own enum.



const Direction = enum(u8) {
    Up,
    Down,
    Left,
    Right,
};

export fn update(dir: Direction) void {
    switch (dir) {
        .Up => { WORLD.playery -= 1; },
        .Down => { WORLD.playery += 1; },
        .Left => { WORLD.playerx -= 1; },
        .Right => { WORLD.playerx += 1; },
        else => {},
    }
    return;
}


Enter fullscreen mode Exit fullscreen mode

This is also prone to fail since we're not doing bounds check, and this can lead to integer underflow/overflow or just plain going off the map entirely. We need to provide checks to assure we're still going to be on the map after an update.



export fn update(dir: Direction) void {
    if ((dir == .Up) and (WORLD.playery > 0))
        WORLD.playery -= 1;
    if ((dir == .Down) and (WORLD.playery < HEIGHT))
        WORLD.playery += 1;
    if ((dir == .Left) and (WORLD.playerx > 0))
        WORLD.playerx -= 1;
    if ((dir == .Right) and (WORLD.playerx < WIDTH))
        WORLD.playerx += 1;
    return;
}


Enter fullscreen mode Exit fullscreen mode

We lose our switch case, but that happens when complex logic starts being required. This moves our player around, but doesn't actually change any of the cells, so nothing really happens other than two numbers being pushed around.

This also doesn't really do anything for us quite yet either, as it isn't contextually aware of our world, nor does it mutate the cells to reflect our new state. We should store new co-ordinates that represent our target cell that we want to move to, and apply logic based on what is in that cell. If the goal cell is invalid, we don't do anything and exit early.



/// Update our world by attempting to move the player somewhere
export fn update(dir: Direction) void {
    var goalx = WORLD.playerx;
    var goaly = WORLD.playery;
    if ((dir == .Up) and (goaly > 0))
        goaly -= 1;
    if ((dir == .Down) and (goaly < HEIGHT - 1))
        goaly += 1;
    if ((dir == .Left) and (goalx > 0))
        goalx -= 1;
    if ((dir == .Right) and (goalx < WIDTH - 1))
        goalx += 1;

    var goalpos = calc_pos(goalx, goaly);
    if (goalpos == calc_pos(WORLD.playerx, WORLD.playery))
        return;

    var dest_ent = WORLD.cells[goalpos];
    switch (dest_ent) {
        .Empty => {
            _ = set_pos(WORLD.playerx, WORLD.playery, .Empty);
            _ = set_pos(goalx, goaly, .Player);
        },
        else => {},
    }
    return;
}


Enter fullscreen mode Exit fullscreen mode

This is a lot of ground to cover, but let's go through it slowly.

First, we need to calculate a "goal" position to move the player from. This is based on it's original co-ordinates in the world state, so we grab that. Then we apply checks based on the given Direction value we receive, and apply a range check if we're going to move outside of our world or not. Then we modify the goal co-ordinates if it's a valid move.

Next, we check if the goal position we want to move to is valid, and not the one we're currently standing on. If it's the one we're standing on, exit early, because there's no additional work to do.

Last, we do a switch over the Entity value that's in our goal index position. If it's an empty space, we move our player, and then we set an empty space where we previously were. To make it easier, I made an update to set_pos() to include moving the player if we're setting a player entity, this way there can only ever be one player in the world.



export fn set_pos(x: u8, y: u8, v: Entity) bool {
    var index = calc_pos(x, y);
    if (index < W_SIZE) {
        switch (v) {
            .Empty => { WORLD.cells[index] = .Empty; },
            .Player => {
                WORLD.playerx = x;
                WORLD.playery = y;
                WORLD.cells[index] = .Player;
            },
            .Wall => { WORLD.cells[index] = .Wall; },
        }
        return true;
    }
    return false;
}


Enter fullscreen mode Exit fullscreen mode

We need to update the init() function to include an original player position.



export fn init() void {
    _ = set_pos(4, 4, Entity.Wall);
    _ = set_pos(4, 3, Entity.Wall);
    _ = set_pos(4, 5, Entity.Wall);
    _ = set_pos(3, 4, Entity.Wall);
    _ = set_pos(5, 4, Entity.Wall);

    // set the player
    _ = set_pos(0, 0, Entity.Player);
}


Enter fullscreen mode Exit fullscreen mode

This works, and we can try it out by manually punching commands into the console.



>>> Game.update(1) // moves player down
>>> Game.update(0) // moves player up
>>> Game.update(2) // moves player left
>>> Game.update(3) // moves player right


Enter fullscreen mode Exit fullscreen mode

Here's what it should look like now since we already drew the player in the JS earlier.

Our world with our player block in red

If you move him around, you'll see we don't collide and move into grey wall blocks. Why? Because we checked the target cell, and we never wrote logic for when a wall is encountered. Therefore, if a wall is found, we just don't do anything.

Binding the Controls

It would be very convenient if we could not use the JS console to move our player, and instead maybe use keyboard keys like the arrow keys, or WASD instead. This is something we'll have to patch on the JS side, so let's go back to that JS code and find a way.

Adding a key event to a web page involves adding a reactionary hook function to the DOM model. When keys are pressed down (or any other kind of user I/O event occurs), JavaScript can be executed if bound through an event "listener" function. Let's attempt to bind a listen to the canvas element itself.



window.document.body.addEventListener('keydown', function(evt){
    console.log(evt);
});


Enter fullscreen mode Exit fullscreen mode

This will first tell us what kind of events we're receiving (if any) for the keydown type of events. Using console.log, we can see what events we're getting exactly, so let's grab some sample output.



keydown { target: body, key: "a", charCode: 0, keyCode: 65 }
keydown { target: body, key: "d", charCode: 0, keyCode: 68 }
keydown { target: body, key: "f", charCode: 0, keyCode: 70 }
keydown { target: body, key: "ArrowUp", charCode: 0, keyCode: 38 }


Enter fullscreen mode Exit fullscreen mode

It's pretty basic; we get an object file that shows the key name and the key raw code value. We can map any group of these using either string names or integer values, but for now I will go with string values since it's easier to read at a glance, instead of questioning what each keycode is.



window.document.body.addEventListener('keydown', function(evt){
    if (!Game.loaded)
        return;
    if (evt.key == "w" || evt.key == "ArrowUp")
        Game.update(0);
    if (evt.key == "s" || evt.key == "ArrowDown")
        Game.update(1);
    if (evt.key == "a" || evt.key == "ArrowLeft")
        Game.update(2);
    if (evt.key == "d" || evt.key == "ArrowRight")
        Game.update(3);
});


Enter fullscreen mode Exit fullscreen mode

First we check if the game even loaded (which I toggle to true when the WASM is initiated earlier), then we run through the different string names of all the keys. I want it to be WASD and arrow keys to move around so it's intuitively easy to understand. This unfortunately doesn't work on mobile devices, so that will be a whole different category of inputs we're not equipped for just yet.

...and it still doesn't work. Here's a JavaScript gotcha we didn't prepare for - the Game.loaded variable is completely undefined. Why? Look at this snippet of code.



Game = result.instance.exports;
Game.loaded = true;
Game.running = true;
Game.init();
main(); // begin


Enter fullscreen mode Exit fullscreen mode

Seems valid, but it isn't. The WASM object we get from result.instance.exports seems to be completely immutable, but JavaScript didn't throw an error for it. The re-assignment changes and even remove old variables we tried binding in our original Game object definition, so these no longer hold true or store data. Oops!

No matter, we'll use another object to store metadata which I'll just call AppState at the top level near our original Game object. That original Game object is still handy for error handling in the case the WASM file doesn't load at all.



var Game = {
    'init': null,
    'get_pos': null,
    'set_pos': null,
    'update': null,
};

var AppState {
    'loaded': false,
    'running': true,
};


Enter fullscreen mode Exit fullscreen mode


window.document.body.addEventListener('keydown', function(evt){
    if (!AppState.loaded)
        return;
    if ((evt.key == "w") || (evt.key == "ArrowUp"))
        Game.update(0);
    if ((evt.key == "s") || (evt.key == "ArrowDown"))
        Game.update(1);
    if ((evt.key == "a") || (evt.key == "ArrowLeft"))
        Game.update(2);
    if ((evt.key == "d" || evt.key == "ArrowRight"))
         Game.update(3);
});



Enter fullscreen mode Exit fullscreen mode

After switching all the things over, it is now working much better.

Adding More Gameplay

This game is still really boring. I did mention we could add a goal to move to, but that would still be considerately boring. Maybe we can try to add a level of fun by adding a different type of goal.

This game shares resemblance to top-down 2D games like The Legend of Zelda, so it makes sense to me that perhaps we could include a bit of gameplay from those style of games.

Maybe with enough time we could make something as cool as this game

A re-occurring theme in these top-down style games is the ability to "move" something else in the world dynamically. For instance, a moving block to solve a puzzle. We can make it a bit like Klotski, where the block must hit a goal to win.

Peak gaming from 1995, Klotski is the original premier gaming software to this day

First, we need new entities.



const Entity = enum(u8) {
    Empty,
    Player,
    Wall,
    Block,
    Goal,
};


Enter fullscreen mode Exit fullscreen mode

Now we need new statements to render these new entities.



var main = function() {
    console.log("Main function started");

    var loop = function() {
    // clear the background
    ctx.fillStyle = "white";
    ctx.fillRect(0, 0, 100, 100);
    for(var x = 0; x < 10; x++) {
        for(var y = 0; y < 10; y++) {
        var cell = Game.get_pos(x, y);
        if (cell == 1)
            ctx.fillStyle = "red";
        else if (cell == 2)
            ctx.fillStyle = "grey";
        else if (cell == 3)
            ctx.fillStyle = "blue"; // push block
        else if (cell == 4)
            ctx.fillStyle = "green"; // goal
        else
            ctx.fillStyle = "white";
        ctx.fillRect(x*10, y*10, (x*10)+10, (y*10)+10);
        }
    }

    // loop to next frame
    if (AppState.running)
        window.requestAnimationFrame(loop);
    };
    loop();
};


Enter fullscreen mode Exit fullscreen mode

Now we need to update our init() to include these new entities.



export fn init() void {
    _ = set_pos(4, 4, Entity.Wall);
    _ = set_pos(4, 3, Entity.Wall);
    _ = set_pos(4, 5, Entity.Wall);
    _ = set_pos(3, 4, Entity.Wall);
    _ = set_pos(5, 4, Entity.Wall);

    // set the player and the goal
    _ = set_pos(0, 0, Entity.Player);
    _ = set_pos(5, 5, Entity.Goal);

    // set a block to push
    _ = set_pos(3, 1, Entity.Block);
    return;
}


Enter fullscreen mode Exit fullscreen mode

Now we have a new game world we can walk around in and check out. We cannot move anything or walk into the goal, which is fine and testament to some sturdy logic we wrote.

Our game world with a goal and a blue block

Adding the logic to the push block will add a new layer of logic we need to account for. Now we need to calculate another new destination, then see if we can move the block to that. Annoying, but could be refactored later down the line if needed.



// .. inside our player moving logic
.Block => {
    // move this block somewhere
    var newblockx = goalx;
    var newblocky = goaly;
    if ((dir == .Up) and (newblocky > 0))
        newblocky -= 1;
    if ((dir == .Down) and (newblocky < HEIGHT - 1))
        newblocky += 1;
    if ((dir == .Left) and (newblockx > 0))
        newblockx -= 1;
    if ((dir == .Right) and (newblockx < WIDTH - 1))
        newblockx += 1;

    var block_dest = calc_pos(newblockx, newblocky);
    if (block_dest == goalpos)
        return;
    var block_dest_ent = WORLD.cells[block_dest];
    switch (block_dest_ent) {
        .Empty => {
            _ = set_pos(WORLD.playerx, WORLD.playery, .Empty);
            _ = set_pos(goalx, goaly, .Player);
            _ = set_pos(newblockx, newblocky, .Block);
        },
        .Goal => {
            _ = set_pos(WORLD.playerx, WORLD.playery, .Empty);
            _ = set_pos(goalx, goaly, .Player);
        },
        else => {},
    }
}


Enter fullscreen mode Exit fullscreen mode

And now we can push the block around a bit to try it out.

The blue block is now pushable by the player square

But this isn't without it's flaws, because this can happen.

A block stuck in a corner, now unmovable

A simple change to the event listener, we can trigger a re-initialization of the world by calling init() again. This can hilariously lead to errors, but we're gonna worry about that later.



window.document.body.addEventListener('keydown', function(evt){
    if (!AppState.loaded)
    return;
    if ((evt.key == "w") || (evt.key == "ArrowUp"))
    Game.update(0);
    if ((evt.key == "s") || (evt.key === "ArrowDown"))
    Game.update(1);
    if ((evt.key === "a") || (evt.key === "ArrowLeft"))
    Game.update(2);
    if ((evt.key === "d" || evt.key === "ArrowRight"))
    Game.update(3);
    if (evt.key == "r")
    Game.init();
});


Enter fullscreen mode Exit fullscreen mode

By pressing r after moving the block, you end up with a funny "error" (it's not really an error, more unexpected behavior).

Multiple blue blocks appear because we never de-initialize all entities for a full reset

Winning

To close out this series, we're going to add one last thing - a win condition. You've already "won" by removing the blue block entirely, but how does the JS know when it's all said and done?

To do this we need a new flag, so we're going to modify the world state struct definition one last time.



const State = struct{
    playerx: u8,
    playery: u8,
    victory: bool,
    cells: [W_SIZE]Entity,
};

var WORLD = State{
    .playerx = 0,
    .playery = 0,
    .victory = false,
    .cells = undefined, 
};


Enter fullscreen mode Exit fullscreen mode

A Boolean flag now exists to tell us when the game is effectively over. Pressing r will be how you reset the game back, and can start playing again. This isn't public, so we need to write a function to exposed it to JavaScript.



export fn is_won() bool {
    return WORLD.victory;
}


Enter fullscreen mode Exit fullscreen mode

Now we can call it from JavaScript to write some text on screen when the game is over.



// in our rendering code
if (Game.is_won()) {
    ctx.font = '18px serif';
    ctx.fillStyle = "Black";
    ctx.fillText('You won!', 10, 15);
}


Enter fullscreen mode Exit fullscreen mode

Lastly, let's update our update() function to change the victory flag when the game ends, and we can also change init() to reset the victory flag to start over.



export fn init() void {
    WORLD.victory = false;
    _ = set_pos(4, 4, Entity.Wall);
    _ = set_pos(4, 3, Entity.Wall);
    _ = set_pos(4, 5, Entity.Wall);
    _ = set_pos(3, 4, Entity.Wall);
    _ = set_pos(5, 4, Entity.Wall);

    // set the player and the goal
    _ = set_pos(0, 0, Entity.Player);
    _ = set_pos(5, 5, Entity.Goal);

    // set a block to push
    _ = set_pos(3, 1, Entity.Block);
    return;
}


Enter fullscreen mode Exit fullscreen mode


// inside the update() function near the end
.Goal => {
    _ = set_pos(WORLD.playerx, WORLD.playery, .Empty);
    _ = set_pos(goalx, goaly, .Player);
    WORLD.victory = true; // you win!
},


Enter fullscreen mode Exit fullscreen mode

And with all that, we've finally achieved real victory.

The blue block army was defeated in this screenshot

Plugging the Leaks

There's a few things to go over last, and it's due to the differences in how Zig creates a WASM file, but interactions with JavaScript can be flaky because they don't certain abstractions with each other.

First off, we have an enum type called Entity, which is a range of values conforming to a singular type. These enumerated names all have differently bound values that Zig understands, but JavaScript does not. For instance, check this out.



>>> Game.set_pos(4, 4, 1) // valid, sets a player
1
>>> Game.set_pos(4, 4, 255) // ?? what is this??
1
>>> Game.set_pos(4, 4, 20000) // not even 8-bit now
1
>>> Game.get_pos(4, 4)
32 // ????


Enter fullscreen mode Exit fullscreen mode

Because the signature of set_pos is set_pos(u8, u8, Entity), it's C-language definition looks more like set_pos(u8, u8, u8), meaning it can support any value of eight bits. There isn't a restriction at this level, so using 255 as an Entity value is completely valid to the program, but invalid to us as game developers. This is a bit of a hole that should be plugged for safety.

We can define some level of safety by ensuring our input is an Entity value or not. This involves a Zig-level switch to check for all possible Entity values.



export fn set_pos(x: u8, y: u8, v: Entity) bool {
    var index = calc_pos(x, y);
    if (index < W_SIZE) {
        switch (v) {
            .Empty => { WORLD.cells[index] = .Empty; },
            .Player => {
                WORLD.playerx = x;
                WORLD.playery = y;
                WORLD.cells[index] = .Player;
            },
            .Wall => { WORLD.cells[index] = .Wall; },
            .Goal => { WORLD.cells[index] = .Goal; },
            .Block => { WORLD.cells[index] = .Block; },
            else => { return false; },
        }
        return true;
    }
    return false;
}


Enter fullscreen mode Exit fullscreen mode

This might look reasonable, except this is actually a compiler error! Since we have five elements in Entity, this enum is considered 100% checked and safe in Zig's eyes. But for us, translating Zig into C-level code for WASM, this is not concrete.

There are two trains of thought here, and I am going to pick the one of least resistance after a bit of explaining. Our choices are:

  • bind all possible values of Entity on the JS side, making it easier to use set_pos() by using binded values instead of raw integers.
  • create a .Null property in the Entity enum, don't match against it in the switch, fallback to an else clause, and let Zig view that as "valid".

The lesser of two evils here is using the latter, and tricking Zig. Some may take issue with this, but I think it's a better work-around than attempting to fix the JS side, where there are less restrictions. An Entity.Null doesn't take up any extra space since the Entity enum is only a u8, and those addresses are already defined, so it's not like we're adding major overhead. This is simply a precaution I'm willing to make in the name of stricter safety.

With this set of code, Zig no longer errors on compile and we have a new layer of safety in our interface.



const Entity = enum(u8) {
    Empty,
    Player,
    Wall,
    Block,
    Goal,
    Null, // goes last, otherwise is the default value
};

fn set_pos(x: u8, y: u8, v: Entity) bool {
    var index = calc_pos(x, y);
    if (index < W_SIZE) {
        switch (v) {
            .Empty => { WORLD.cells[index] = .Empty; },
            .Player => {
                WORLD.playerx = x;
                WORLD.playery = y;
                WORLD.cells[index] = .Player;
            },
            .Wall => { WORLD.cells[index] = .Wall; },
            .Block => { WORLD.cells[index] = .Block; },
            .Goal => { WORLD.cells[index] = .Goal; },
            else => { return false; },
        }
        return true;
    }
    return false;
}


Enter fullscreen mode Exit fullscreen mode

Now using set_pos() should start showing some safer results.



>>> Game.set_pos(1, 1, 20000)
0
>>> Game.set_pos(1, 1, 1)
1


Enter fullscreen mode Exit fullscreen mode

This helps to further solidify the abstraction of the game to make it less leaky and error prone. The user might get a little crazy and try to nullify the entire map! We can secure our code more by making set_pos() not a publicly exported function as well, and since our JS doesn't manipulate the board, the user shouldn't be affected by this at all.

The other issue that can occur is what happens when the game resets and is re-initialized. In no capacity does the world get initialized to an empty state, and we cannot rely on the undefined keyword to do this for us. Simply setting something to undefined doesn't act as a means of setting an array full of values, unless it's the first time being initialized.

We can fix this by changing init() to fill values in the array all the way with empty values instead using a simple while loop.



export fn init() void {
    var index = 0;
    while (index < W_SIZE) : (index += 1) {
        WORLD.cells[index] = .Empty;
    }
    WORLD.victory = false;
    _ = set_pos(4, 4, Entity.Wall);
    _ = set_pos(4, 3, Entity.Wall);
    _ = set_pos(4, 5, Entity.Wall);
    _ = set_pos(3, 4, Entity.Wall);
    _ = set_pos(5, 4, Entity.Wall);

    // set the player and the goal
    _ = set_pos(0, 0, Entity.Player);
    _ = set_pos(5, 5, Entity.Goal);

    // set a block to push
    _ = set_pos(3, 1, Entity.Block);
    return;
}


Enter fullscreen mode Exit fullscreen mode

Now the extra blue block glitch will go away after each reset. I am sad to see it go, personally.

Wrap-Up

We're slowly inching forward in better understanding how to use Zig for WebAssembly purpsoses. This was an important write-up for me because I now feel a little more confident in writing WASM with Zig, and can now start possibly focusing on developing more games with Zig targeting WASM.

The next write-up I do, however long it may take, I would like to delve into more complex things like using asset images and bringing that into JavaScript to use on a Canvas. This project could be extended to load levels or a score counter, but I will implement those on the side, as they would be slightly trivial to do. Check out my GitHub repo for more developments on it.

If you've made it this far, thank you for reading. I apologize if it seems too long, but hopefully the length is what will make it able to provide resourceful info for those looking to delve into WebAssembly with Zig.

Top comments (0)