Back to the blog

Making a Game - Hard Mode - Part 1

October 2nd, 2023

This series was previously going to be about Mach + Zig. Parts 1 - 3 of those are here:

I've quickly realized that it might be "funner" if I break this out even more and increase the difficulty required to make the game I want to make.

Mach will circle back into the picture but I would prefer to have my game broken out in such a way that the renderer doesn't actually matter.

Making core game functions in Zig lets me compile that to whatever I want, including WASM in case I want to do a full proof of concept on the web.

Moving forward, I will mix and match some of the rendering techniques. Rendering techniques I want to implement:

  • Pure web (canvas and/or webgl/webgpu)
  • Text based - kinda like a MUD in the old days - either on a webpage or in the command line
  • Mach - desktop + later webgl/webgpu

The game should be able to be played on as many platforms as possible. I will start by using web technologies to do that.

No corner cutting. I will try my best to use the best techniques I can think of or find even if easier ones are available. I want to demonstrate that setting a high bar for yourself in the programming is not only achievable but probably the most desirable path in most cases.

The Plan

  • Create at least one basic function of my game in zig
  • Compile to wasm
  • Plop wasm file into simple HTML file
  • Simply call wasm function(s) via javascript
  • Maybe use function{s} to do something on screen

List of Basic Game Functions in Zig

My list of functions I want to start with:

  • World
    • Set and get dimensions
    • Blocked coordinates where nobody can walk over
  • Entity
    • Name
    • World position
    • Move up/down/left/right
    • Health
  • Simple entity collision
  • Simple world blocked coordinate collision
  • Two entities
    • Attack each other, 1hp decrease per attack
  • Game is over when one entity wins
  • For now, player controls one entity, second entity only ever attacks *after* player attacks first, one attack a time

Step 1 - The World

I'm gonna wing it and create some structs that I think I need to get started.


const SIZE = struct {
    width: i32,
    height: i32,
};

const World = struct { size: SIZE };

var w = World{ .size = SIZE{ .width = 3, .height = 3 } };
                

const Entity = struct { health: i32 };

var artificial = Entity{ .health = 10 };
var player = Entity{ .health = 10 };
                

I need to know which positions in the world are empty or blocked. Ideally I can just represent this in binary format. Maybe an array of binary rows & columns where 0 = empty and 1 = blocked.


const world_data = 0b1_000_010_000;
                

The above is a binary mask. I could use an array as well. That might make functions like "getWorldPosition(x, y)" easier, potentially.


const world_data = [_]i32{ 0, 0, 0, 0, 1, 0, 0, 0, 0 };
                

Let's not overcomplicate this right now.

Step 2 - Entities Should Move Around World

Moving entities around and checking for collisions can get complicated. Let's keep it simple first. No collision detection to begin with.

Each entity can just have its own simple move functions. Let's create a struct for managing positions. Then we update the entity struct to include a basic move up function.


const POSITION = struct {
    x: i32,
    y: i32,
};

const Entity = struct {
    health: i32,
    position: POSITION,
    fn moveUp(self: *Entity) bool {
        self.position.y -= 1;

        return true;
    }
};

var artificial = Entity{ .health = 10, .position = POSITION{ .x = 2, .y = 2 } };
var player = Entity{ .health = 10, .position = POSITION{ .x = 0, .y = 0 } };
                

This is as simple as it gets and has zero checks or balances. We can add those after.

We want to test whether this works. In order to do that we at *least* need a manual way to get the entities position. A "currentPosition" function is simple enough.


export fn currentPosition(stateEntityIndex: i32) i32 {
    if (stateEntityIndex == 0) {
        return @intCast(i32, @ptrToInt(&player.position));
    } else {
        return @intCast(i32, @ptrToInt(&artificial.position));
    }
}
                

What's really going on here?

First, stateEntityIndex is really just a way to tell the function whether we want the position of the player or the position of the artificial entity. It's poorly named but good enough for now.

When you load a WASM file into the browser for JS to interact with, there is a pool of memory that's made available for WASM to use. This memory holds all the data happening inside the WASM operations. JS also has access to this memory.

In our case, we have a portion of that memory dedicated to holding the players x and y position, for example. We need to tell JS two things, normally, about finding the values of our memory. One, where is our memory located in the vast memory pool? Two, how long is the memory? Imagine the memory being split up into an array.

As a matter of fact, you can convert the WASM memory into a JS memory using something like UInt32Array (or similar).

The currentPosition function above is going to return the index where the position values for X and Y live in the memory. We know that the length of memory is probably 2 because we really only have 2 values right now, X and Y.

We'll look at the output of the WASM function in JS later on and bring this full circle.

In a similar vein, we may as well flex this a bit and get our world data too. Our world data could be of a varied length even though we know, right now, it's a length of 9 values.


export fn worldDataLength() i32 {
    return world_data.len;
}

export fn worldData() i32 {
    return @intCast(i32, @ptrToInt(&world_data));
}
                

Let's stop here and test out our work.

zig build-lib YOURFILENAMEHERE.zig -target wasm32-freestanding -dynamic -O ReleaseFast

I called my file test.zig just because it's easy for me to remember.

You should end up with test.wasm.

I'm going to end up running this on a webpage so I simply create a new index.html file, put some boilerplate HTML code in there and then, in the head, I have a custom javascript tag.


const importObject = {
    imports: {
        multiply(first_number, second_number) {},
        currentPosition(stateEntityIndex) {},
        moveUp(stateEntityIndex) {},
        worldData() {},
        worldDataLength() {}
    },
};
fetch("test.wasm")
    .then((response) => response.arrayBuffer())
    .then((bytes) => WebAssembly.instantiate(bytes, importObject))
    .then((results) => {
        var x_y_length = 2;
        var position = results.instance.exports.currentPosition(1);
        console.log('Memory Position: ' + position);
        var array = new Int32Array(results.instance.exports.memory.buffer.slice(position, (position + (4 * x_y_length))));
        console.log('X: ' + array[0]);
        console.log('Y: ' + array[1]);

        results.instance.exports.moveUp(1);

        var position = results.instance.exports.currentPosition(1);
        console.log('Memory Position: ' + position);
        var array = new Int32Array(results.instance.exports.memory.buffer.slice(position, (position + (4 * x_y_length))));
        console.log('X: ' + array[0]);
        console.log('Y: ' + array[1]);

        var position = results.instance.exports.worldData();
        console.log('Memory Position: ' + position);
        var length = results.instance.exports.worldDataLength();
        console.log('Memory Length: ' + length);
        var array = new Int32Array(results.instance.exports.memory.buffer.slice(position, (position + (4 * length))));
        console.log(array);
    });
                

Let's take note of a few things.

You must tell JS what to expect from the WASM file it's loading. That is the importObject variable. You'll see it's just a mapping of whatever the exported WASM functions are.

You'll note that I also use the "moveUp" function so I expect that the artificial entities position on the y coordinate will go from 3 to 2 in my output.

You can see the output of the different data for positions and world data. Here's what my output looks like at this point in my Google Developer Console on Chrome.


Memory Position: 65588
X: 3
Y: 3
Memory Position: 65588
X: 3
Y: 2
Memory Position: 65536
Memory Length: 9
Int32Array(9) [0, 0, 0, 0, 1, 0, 0, 0, 0]
                

As I expected, the artificial entity did, indeed, move.

Our world data lives in the memory starting at index 65536 and has a length of 9. This is where the ".slice" portion comes in handy. We essentially carve out the section of memory that relates to the data we're after.

Since our memory slots are a byte each we use 4 multiplied by length to get all values of the world data array. In this case, we do 9 multiplied by 4, starting at position 65536 which ends up being a total of 36 and putting us at position 65572 in the memory pool.

Different memory sizes and arrays might have different positions and lengths but this proves the point and demonstrates that we're off to a decent start.

Let's just finish up with all other move actions then.


const Entity = struct {
    health: i32,
    position: POSITION,
    fn moveUp(self: *Entity) bool {
        self.position.y -= 1;

        return true;
    }
    fn moveDown(self: *Entity) bool {
        self.position.y += 1;

        return true;
    }
    fn moveLeft(self: *Entity) bool {
        self.position.x -= 1;

        return true;
    }
    fn moveRight(self: *Entity) bool {
        self.position.x += 1;

        return true;
    }
};

...

export fn moveUp(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        return player.moveUp();
    } else {
        return artificial.moveUp();
    }
}
export fn moveDown(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        return player.moveDown();
    } else {
        return artificial.moveDown();
    }
}
export fn moveLeft(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        return player.moveLeft();
    } else {
        return artificial.moveLeft();
    }
}
export fn moveRight(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        return player.moveRight();
    } else {
        return artificial.moveRight();
    }
}
                

Step 3 - Collision Detection

There are some rules that need to be implemented.

  • Out of bounds should not be allowed. Example, an entity should never reach -1 in their y position and should never exceed max size of world.
  • Two entities cannot overlap each other.
  • No entity can walk on any spot that is "blocked" in the world data.

I'm realizing my move functions in the entity struct do not need to return true or false for our starter setup here. I will void them out and make them even simpler.


const Entity = struct {
    health: i32,
    position: POSITION,
    fn moveUp(self: *Entity) void {
        self.position.y -= 1;
    }
    fn moveDown(self: *Entity) void {
        self.position.y += 1;
    }
    fn moveLeft(self: *Entity) void {
        self.position.x -= 1;
    }
    fn moveRight(self: *Entity) void {
        self.position.x += 1;
    }
};
                

Our exported move functions can contain collision checks and return any true or false values based on outcomes.


export fn moveUp(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        player.moveUp();
        if (player.position.y < 0) {
            player.position.y = 0;
            return false;
        }
        return true;
    } else {
        artificial.moveUp();
        if (artificial.position.y < 0) {
            artificial.position.y = 0;
            return false;
        }
        return true;
    }
}
export fn moveDown(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        player.moveDown();
        if (player.position.y >= w.size.height) {
            player.position.y = (w.size.height - 1);
            return false;
        }
        return true;
    } else {
        artificial.moveDown();
        if (artificial.position.y >= w.size.height) {
            artificial.position.y = (w.size.height - 1);
            return false;
        }
        return true;
    }
}
export fn moveLeft(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        player.moveLeft();
        if (player.position.x < 0) {
            player.position.x = 0;
            return false;
        }
        return true;
    } else {
        artificial.moveLeft();
        if (artificial.position.x < 0) {
            artificial.position.x = 0;
            return false;
        }
        return true;
    }
}
export fn moveRight(stateEntityIndex: i32) bool {
    if (stateEntityIndex == 0) {
        player.moveRight();
        if (player.position.x >= w.size.width) {
            player.position.x = (w.size.width - 1);
            return false;
        }
        return true;
    } else {
        artificial.moveRight();
        if (player.position.x >= w.size.width) {
            player.position.x = (w.size.width - 1);
            return false;
        }
        return true;
    }
}
                

Now my move functions in Zig will check for out of bounds and default you to the edges of the map if you attempt to exceed aforementioned edges.

There are a few ways to achieve entity collision detection but we already have a world data array. What if we just updated that array with a value of 2 on any x/y coordinate an entity lives on. That would mean all we have to do is check the world data array for any non-zero value to see if there's a collision.

We should have an exportable function to be able to check the value of any given x/y coordinate in world data.

We should have an internal function to set the value of any given x/y coordinate in world data. We want this internal because we probably never want our JS to be able to set these values.

First, our world data is no longer a constant. Let's update it.

var world_data = [_]i32{ 2, 0, 0, 0, 1, 0, 0, 0, 2 };

Let's setup our functions for world coordinate get/set.


pub fn setWorldDataAtCoordinates(x: i32, y: i32, value: i32) void {
    var index = @intCast(usize, multiply(x, y));
    world_data[index] = value;
}
pub export fn getWorldDataAtCoordinates(x: i32, y: i32) i32 {
    var index = @intCast(usize, multiply(x, y));
    return world
    _data[index];
}
                

We also have to update our initial multiply function to use i32 instead of u8.


export fn multiply(x: i32, y: i32) i32 {
    return (y * w.size.width) + x;
}
                

Then each of our move functions has to essentially do the following:

  • After (or before) a move, check the current value of the intended world data coordinates
  • If the value is 0, allow the move
  • If the value is NOT 0 then do not allow the move (or reverse it)
  • If the move is successful, set the world data coordinate value to 2 to indicate that the entity lives there
  • Make sure to reset the previous entity world data coordinate to 0 (so we don't have a bunch of 2s)

export fn moveUp(stateEntityIndex: i32) bool {
    var entity: *Entity = undefined;
    if (stateEntityIndex == 0) {
        entity = &player;
    } else {
        entity = &artificial;
    }
    var intended_y = (entity.position.y - 1);
    var world_data_value = getWorldDataAtCoordinates(entity.position.x, intended_y);
    if (intended_y < 0) {
        return false;
    }
    if (world_data_value > 0) {
        return false;
    }
    setWorldDataAtCoordinates(entity.position.x, entity.position.y, 0);
    entity.moveUp();
    setWorldDataAtCoordinates(entity.position.x, entity.position.y, 2);
    return true;
}
                

This is a very hacky way to do it but we'll refactor it later. For now, it serves the purpose.

If I run my HTML / JS code again, my output of the world data should show a value of 2 where my entities are after moving artificial entity up a few times (which should place them at the top right corner of the world).

Int32Array(9) [2, 0, 2, 0, 1, 0, 0, 0, 0]

Nice. We have movement so far. Let me add a moveLeft x 2 to see if the artificial entity moves over the player.

Int32Array(9) [2, 2, 0, 0, 1, 0, 0, 0, 0]

Nice. Let me make sure the artificial entity cannot move down into the blocked off world space. Add a moveDown.

Int32Array(9) [2, 2, 0, 0, 1, 0, 0, 0, 0]

Ok everything checks out with collision detection from a cursory test.

A little bit of clean up on the JS side.


const importObject = {
    imports: {
        multiply(x, y) {},
        currentPosition(stateEntityIndex) {},
        moveUp(stateEntityIndex) {},
        worldData() {},
        worldDataLength() {},
        getWorldDataAtCoordinates(x, y) {}
    },
};
fetch("test.wasm")
    .then((response) => response.arrayBuffer())
    .then((bytes) => WebAssembly.instantiate(bytes, importObject))
    .then((results) => {
        let _GAME = results.instance.exports;
        let GAME = {
            getWorldData: function () {
                var memory_position = _GAME.worldData();
                console.log('Memory Position: ' + memory_position);
                var memory_length = _GAME.worldDataLength();
                console.log('Memory Length: ' + length);
                var array = new Int32Array(_GAME.memory.buffer.slice(memory_position, (memory_position + (4 * memory_length))));
                console.log(array);
            },
            getEntityPosition: function (which) {
                var x_y_length = 2;
                var memory_position = _GAME.currentPosition(which);
                console.log('Memory Position: ' + memory_position);
                var array = new Int32Array(_GAME.memory.buffer.slice(memory_position, (memory_position + (4 * x_y_length))));
                console.log(array[0], array[1]);
            },
            moveUp: function (which) {
                console.log('Move Up: ' + _GAME.moveUp(which));
            },
            moveDown: function (which) {
                console.log('Move Down: ' + _GAME.moveDown(which));
            },
            moveLeft: function (which) {
                console.log('Move Left: ' + _GAME.moveLeft(which));
            },
            moveRight: function (which) {
                console.log('Move Right: ' + _GAME.moveRight(which));
            }
        };

        GAME.getWorldData();

        GAME.getEntityPosition(1);

        GAME.moveUp(1);
        GAME.getEntityPosition(1);

        GAME.moveUp(1);
        GAME.getEntityPosition(1);

        GAME.moveLeft(1);
        GAME.getEntityPosition(1);

        GAME.moveLeft(1);
        GAME.getEntityPosition(1);

        GAME.moveDown(1);
        GAME.getEntityPosition(1);

        GAME.getWorldData();
    });
                

Step 4 - Attacks

The simplest setup is similar to the move functions. It would be something akin to attack{Direction} and, as long as an entity exists in the direction we intended, we succeed the attack.

We can take a lot of cues from the move functions.


export fn attackUp(stateEntityIndex: i32) bool {
    var entity: *Entity = undefined;
    var attackee: *Entity = undefined;
    if (stateEntityIndex == 0) {
        entity = &player;
        attackee = &artificial;
    } else {
        entity = &artificial;
        attackee = &player;
    }
    var intended_y = (entity.position.y - 1);
    var world_data_value = getWorldDataAtCoordinates(entity.position.x, intended_y);
    if (intended_y < 0) {
        return false;
    }
    if (world_data_value != 2) {
        return false;
    }
    attackee.health -= 1;
    return true;
}
                

We should also have a function that gets the health of an entity.


export fn currentHealth(stateEntityIndex: i32) i32 {
    if (stateEntityIndex == 0) {
        return @intCast(i32, @ptrToInt(&player.health));
    } else {
        return @intCast(i32, @ptrToInt(&artificial.health));
    }
}
                

If you update the importObject to include the attack functions and the currentHealth function, you can then just add some attack actions to the end of the chain of GAME functions.


...

GAME.currentHealth(1);
GAME.getWorldData();

GAME.getEntityPosition(1);

GAME.moveUp(1);
GAME.getEntityPosition(1);

GAME.moveUp(1);
GAME.getEntityPosition(1);

GAME.moveLeft(1);
GAME.getEntityPosition(1);

GAME.moveLeft(1);
GAME.getEntityPosition(1);

GAME.moveDown(1);
GAME.getEntityPosition(1);

GAME.getWorldData();

GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
GAME.attackRight(0);
GAME.currentHealth(1);
...
                

We can see the artificial entities health go down to 0 if we watch the console output.

Goals Accomplished (?)

For the most part, we did what we set out to do, although my main question here surrounds the memory allocation in Zig and the way we grab that memory in JS.

I'm not 100% convinced that I'm doing either one of those correctly. I know normally you would allocate a set of memory in the memory allocator in Zig and then release it when done. However, I'm doing none of that. I have to assume Zig is being smart here and knows that I'm specifically doing things.

The JS memory is also a bit weird to me and I wonder if there's a better way to do grab values from WASM memory.

I need to investigate these a bit.

What's nice about this is that I can take these same functions and, with a few tweaks, use them in Mach or in other rendering methods.

What's Next?

Bare minimum I would like to setup a minimal "renderer" for the game on a webpage. My first choice is to setup a MUD/Textual, almost command line, client on a webpage where there's streaming back and forth on the game state based on actions you take.

We could also display a simple world grid in an html canvas and use colors to show the different states of the 3x3 world with some simple buttons to move entities around, attack, show health and anything else we want.

In either (or both) cases I want to show the game is over when the player or artificial entity has no more health.