Developing Maginet
A cross-platform strategy game with Rust/WebAssembly
I spent a couple months trying to see if I can release a game and learn new technologies. Maginet was the result of all my hard work.
The result is Maginet: It is a turn-based strategy game where two guilds of mages battle each other. You can play locally (with a friend or against the AI) or online, on a PC or on your phone. It’s heavily inspired by chess, but has a little bit of its own spirit to it.
Executive summary
In this article, I will be the technical (developing the code) aspects of building a video game like this from scratch. I am going to be focusing on the choices I made, problems I encountered, and present some code to spice it up.
Maginet is developed with Rust and uses wasm-bindgen to run on platforms supporting HTML5. Online multiplayer works via polling over HTTP, much simpler for turn-based games!
Its source code is public and available on GitHub, and it’s available now on Steam! The Steam version comes with online multiplayer support and 28 campaign levels to try out against the AI player. A demo is playable at maginet.evrim.zone or at its itch.io page.
I have a separate article in the works about the mechanical (developing the game) aspects, and the experience of launching a fresh game on Steam. Sign up to my newsletter at the bottom of the article for news on that bit!
Maginet
I had the idea of a turn-based strategy game for a while now, my main motivation was to understand how chess programming methods work, and learn a little bit more about boring-AI (compared to black-box magic AI). In turn, I designed some core mechanics, started prototyping, changed my core mechanics, continued prototyping, changed my core me– ahem.
I also wanted to see how far I can take my Rust skills, and a game is naturally a great way to test them. Logic, interfaces, I/O, sound, design all get put together into one box!
Me!
I’m Evrim! As I write this in 2023, I am 26 years old. I love programming, making video games, bouldering, cooking, and photography. I am particularly in love with programming. Prior to starting out with Maginet, I already had quite a bit of experience with Rust, but I identify as a Python developer considering how I have the most professional experience with that.
Past experience developing games
It’s a bit sad, but my game developer career started and ended rather early. I have been programming since the age of 7 or 8, and went into it with the intention of making games. I spent all the way up to 18 working on various prototypes, the most famous one being Starshock. I really had my shot at the beginnings of the boomer shooter epidemic, alas, I ended up migrating to the Netherlands to begin a new life. This threw off my priorities—which were split between not failing classes and earning enough funds to sustain myself beyond the essentials covered by my parents, although those did not last with the economic crisis Turkey went through and I had to cover for myself during my master’s—and I ended up putting less effort in building games.
I consider myself a good programmer, but I think a good game developer is one who finishes his games. I hadn’t done that. After literal hundreds of prototypes over my life, I never finished a game. That was bothering me very deeply. I had to fix that, I had to launch something on Steam and finally fulfil my childhood dream of becoming a good game developer, a professional and an artisan.
So I began!
Timeline
I have been writing this article since around May 2023, having worked on the game for a couple already. It took me from late-February until October to launch this game, having spent four of those months actively grinding on it. Now that the game is out, and before I begin talking about the fun technical bits, I think major highlights (with GitHub links to the code at the time) are in order.
22 February 2023 began with the initial commit, which renders the board and some mages, which cannot move yet.
6 March 2023 moved through with a shoddy collection of the base prototype of the game, and initial blocks of the networking and server.
17 March 2023 was the day where I built most of the AI, which is just integrating an alpha-beta pruning algorithm (a common chess programming method) to the game.
4 April 2023 was the time where I implemented a ‘main menu’ of sorts for the game, where you could add custom settings, decide if you wanted to play locally, versus AI, or online. It required me to build a UI framework, and spend the next week fixing bugs in my UI framework.
18 May 2023 saw the beginning of the level editor, which remains mostly unchanged besides new functionality. I realised that just battling it out on a fixed board like chess wasn’t quite going to cut it, and with an AI in place, I could make ‘puzzle’ styled levels.
For the time between then and October, where most of the ‘final’ progress on the game was made, I honestly just stopped working on the game. I was busy with life, and it gets in the way sometimes. However, with Steam’s NextFest (an event to higlight up-and-coming games on the platform), I realised that I really had to go pedal to the metal.
4 October 2023 was the start of the second major sprint on the game. I realised that the game’s mechanics left something important, the element of push and pull. I had already prototyped ‘power-ups’ for the game, but failed to get something enjoyable. Having played against the AI a lot, which was by design adaptable to the new feature or mechanics I added to the game, what I had tried wasn’t quite engaging (or even strategically valuable, seeing AI ignore the double-damage powerup I made). This iteration saw three new pickups, and helped shape the game’s final form.
20 October 2023 saw the latest commit for the game up to its launch, and most of October was spent bug-fixing and plumbing everything together. I officially launched the game.
Why Rust?
I got around to trying Rust out quite early on, around 2017, with little luck. The ecosystem for building games was not quite as established as, say, Java at the time. Nowadays the situation is much better, and I feel like I did not quite have the mental development to learn my way around Rust back then anyway.
Back in 2020, I started out using wgpu-rs to build a little platformer game from scratch, but quickly ran into issues where I would write unidomatic and idiotic Rust code that would cost me a lot of time to bodge into something that worked. I worked on this project on and off as a sandbox to get a better feel for Rust and the WebGPU APIs (coming from an OpenGL background).
In 2021 I gave Advent of Code a shot to get used to the basics of borrow checking and stack/heap memory management of Rust, but more importantly the ergonomics of thinking and coding in the ways that Rust requires you to. Nevertheless, despite finishing the year, I did not quite feel ready to jump into a complete video game.
Leetcode challenges have few moving parts and require isolated solutions, whereas video games require you to not just solve these isolated problems, but bring them together into a cohesive, interconnected system.
After doing Advent of Code again in 2022, something clicked, and I felt ready to give it a shot. I began working on Maginet on March 2023.
My experience with Rust, as you can tell, has been a bit of a bumpy ride. It ultimately took me three calendar years to get comfortable enough (while rest of my life was happening in tandem) to go for a commercial game project.
However, after getting past the initial hurdle, Rust has been immensely pleasurable to build complex things in. It helps a lot because it stops me from making stupid mistakes that I internalised. I have to manage nulls via Option
s, cast things explicitly, cannot do dumb stuff like modifying an array while iterating it, etcetera. It’s guiding my code in ways that I wasn’t able to myself, and eventually leading to essentially what is a bug-free game (at least from the application perspective). Imagine!
The experience has been far more than just borrow checking, it’s opened up my mind to a lot of concepts that my previous trials with Java, JavaScript, and Python fully obscured in arcane ways. Rust’s compiler is wonderful, and once you get past the ergonomic struggles (which I did by just going through Advent of Code), you’re set.
With the fact that I could compile and run Rust on the browser via WebAssembly, I knew that I could leverage the techniques and knowledge I obtained using my JavaScript game prototypes, I could build once and play anywhere.
Technical details and execution
I used quite a few languages so far to build games of various shapes and sizes, I started out with HTML/JS when I was around 9, moved on to Java (being inspired by Minecraft) around 13, tried out Python (pygame, C# (with Unity), C++ (Allegro), and Lua (LÖVE2D) since then.
Having used Rust recently, I firmly believe that it’s a good choice for executing games (but not prototyping them). Once you have a clear design document and a prototype at hand (made in a more ergonomic framework and language), you will reap its benefits.
I also wanted to have a way to play the game on mobile devices, so having a compile-once-run-everywhere option being open via WebAssembly, I decided to go ahead and combine Rust with the Web APIs. All of Maginet’s code, both on the client and the server side, is in Rust. The list of packages is rather small.
Client-server shared
For the client, the main drivers for the WebAssembly components are based on wasm-bindgen
and its sister packages (js-sys
and wasm-bindgen-futures
). I used serde
for all serialisation/deserialisation, which is used for networking packets via JSON. That’s it! All the remaining code required for the client is contained within the project and was written by me. There are a lot of moving parts here, which I will talk about later on in this article.
For the server, I used axum
by the tokio
project, and it acts as a simple HTTP server with a couple of endpoints to serve game assets and process networking requests.
The server and the client share their game logic between them, with the server having the authoritative word on the real game state, applying the same logical controls to enforce the game mechanics as the client.
Engine? HTML5 is enough!
A little bit of a surprise to some is that I did not use an engine. A game engine handles many of the ‘application’ components of a game, that is the rendering, sound, input, UI, etc. that tie into the package in which your game’s mechanics are delivered. I spent a lot of time studying game engines over the course of my amateur indie developer career, so I know quite a bit about how all these application components are built, which technologies they rely on, and how to (again) join the application with the ‘game.’
Short disclaimer: I have quite a lot of experience with HTML5 APIs, and I use JavaScript to prototype game ideas frequently. The Web* APIs are very capable these days, supporting everything from complex 3D rendering (which I do not use in Maginet) to gamepad support. Even building a sound engine took a couple hours (with the hardest part being managing the sound assets than wiring them in with the rest of the game).
I really think that understanding the fundemantal components of a game’s application layer, that is the game loop and input/output layers, is a necessary component of a well-rounded game and application developer. Behind every game engine, there is layers and layers of magic built up by people way smarter than me.
However, Maginet is a simple game. All I really needed was to take touch and mouse input, track passing time to update the game and its visuals, draw some 2D images, play some sound. HTML5 is more than sufficient.
My project’s Cargo file gives an overview of all the various HTML5 API objects that I used, briefly put are:
['CanvasRenderingContext2d', 'CssStyleDeclaration', 'console', 'Document', 'DomRect', 'DomStringMap', 'Element', 'FocusEvent', 'HtmlDocument', 'HtmlElement', 'HtmlCanvasElement', 'HtmlImageElement', 'HtmlInputElement', 'HtmlAudioElement', 'HtmlMediaElement', 'AudioContext', 'AudioBuffer', 'AudioNode', 'GainNode', 'AudioParam', 'AudioDestinationNode', 'AudioBufferSourceNode', 'KeyboardEvent', 'Location', 'Node', 'MouseEvent', 'Performance', 'Touch', 'TouchEvent', 'TouchList', 'Headers', 'Request', 'RequestInit', 'RequestMode', 'Response', 'Storage', 'Window']
Highlights here are CanvasRenderingContext2d
for displaying images with the Canvas API, Audio*
and other ones for playing sounds with the Web Audio API, Touch*
for mobile support, Storage
for an application-level key-value store.
That’s really about what you need to make a game. I get that it’s not the path for everybody to really learn the nitty-gritty of how things work under the hood, but it’s a part of game development that really excites me too. I highly recommend trying out an engine-less path to anybody who’s getting started making games, and have a good know-how on how computers work.
I started the project from wasm-bindgen’s request-animation-frame example, for all that matters. You can gradually build up on it bit by bit, hook up event listeners, and go to town on all HTML5 has to offer!
Drawing things and getting input
wasm-bindgen allows you to pass around references to various JavaScript objects in your code, in turn, most of the drawing code is just passing around a CanvasRenderingContext2d
and takes some parameters to put sprites on the screen.
I wrote the code as I would write JavaScript in the end, which led to wonderful snippets of .clone()
s littered around whenever I had to deal with closures. The following one takes in a MouseEvent
from the mousemove
listener, and passes it onto the App
object (that orchestrates everything) to have it be processed into the internal input state:
{
let app = app.clone();
let bound = bound.clone();
let closure = Closure::<dyn FnMut(_)>::new(move |event: MouseEvent| {
let mut app = app.borrow_mut();
if let Some(bound) = bound.borrow().as_deref() {
app.on_mouse_move(bound, event);
}
});
document()
.add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())?;
closure.forget();
}
It’s rather straightforward, but it took a while to bring it to its present, ugly, messy, but working (!) state. Going through the wasm-bindgen sample code was my main way of working through problems and seeing how certain JavaScript code translates into Rust.
I thoroughly recommend anybody interested in the interfacing part to actually go through the code. All of the client-side code is in plain sight, for graphics, audio, and input (the holy trinity of what you need for a video game).
Using Rust with WebAssembly
I have to make it clear to anybody who wants to follow this path, however, that things are not always straightforward. The build process took a couple of iterations (and I am in the process of improving this with an upcoming game I’m working on), and it’s really not straightforward.
A large part of working with wasm-bindgen (high-level JS API to WebAssembly bindings) to target the web was simply following the examples provided by the project and designing my own process around it.
With Maginet, I run a command which looks like the following:
watchexec -w src -w shared -r -e rs -- wasm-pack build --target web --debug --out-name maginet_aee75fc --out-dir static/js/pkg
I use watchexec
to watch file changes in the src
and shared
folders for files with a .rs
extension (Rust source files), and run wasm-pack
to package the game’s built .wasm
file. The server then serves it as a static resource. It’s not hot-loading, but it worked well enough to prototype and ship the game!
Again, just like with anything else throughout this journey, getting through the intial hurdle was a great opportunity to learn about the tech involved.
WebAssembly as a cross-platform target
As a brief note, WebAssembly is still a bit ’experimental’ as a build target. I was able to fool the borrow checker and cause stack overflows in two instances. One of them was quite curious:
{
// Get a mutable reference to the pointer object (mouse cursor/touch input)
let mut pointer = pointer.borrow_mut();
// The previous pointer state stored in an Option<Box<Pointer>>, and here we take the Box<Pointer> out of the Option, but it's not dropped from the heap despite being useless!
pointer.previous.take();
// Replace the previous state with the current pointer to continue the Pointer cycle
pointer.previous = Some(Box::new(pointer.clone()));
}
The block above for some reason was preventing entire surrounding function (which is the main game loop) from dropping its previous iteration from the stack. I am not even sure if I understand it correctly, but it kept growing the game’s used memory while keeping the accumulated Pointer
objects in memory.
There are times where you think it’s the fault of the compiler, and here, it may have been? I really have no clue.
Besides these, on the other hand, I was able to build one WebAssembly blob that is used in both the web and the deployed version of Maginet. Incredibly handy, and my final iteration of the build process mimicks the experience of using a modern game engine (perhaps handier if I do say so myself).
With more complex games, you will deal with slow build times, but I did not find WebAssembly itself to be a bottleneck in here by any means.
Rust game development without reinventing the wheel via HTML5/WebAssembly
There are many other ways to make games in Rust, and I would argue that my approach is one of the worst ways to get started. I ultimately chose the tool I was the most familiar with, and you should pick one if you don’t have a choice yet. It’s much more important to get started on the product than get lost in the tools.
Are We Game Yet? is the prime index of the Rust game development ecosystem, with the popular Amethyst and Bevy engines being household brands.
Godot the beloved also has Rust bindings which is used by Your Only Move Is Hustle to bring fixed point arithmetics into the engine. These bindings are complete enough that you could write practically all your logic in Rust for your Godot game, and they also are helpful to just bring the Rust performance or low-level access you might need.
Deployment
As the game compiles to run on the browser, my first (and final choice) was Electron. Although it’s a bit overkill to use a 250 megabyte executable wrapper for a game that takes up around 4 megabytes, I found this to be a reasonable compromise. We are no longer in the floppy disk era and I don’t have to pretend to either. Most Steam games come in the gigabytes, even games as simple as Maginet being wrapped in larger-than-browser engine runtimes, using uncompressed assets taking up a ton of space.
Electron simplifies the build process by providing a relatively hassle-free process, but instead of using Electron Forge (which was rather slow for some reason) I opted in for a basic build script that I run to build the wasm package, put files together in the executable, and ask electron-packager to ship it instead. electron-packager is what Forge uses anyway.
I ended up publishing the code for the deployment package as well over at the maginet-deploy repository on GitHub. You can check out the package.json file for the build commands (which are rather simple), and the snippets which build the launch and demo versions in the deploy-electron-… scripts. It’s mainly just copying and shuffling files around, then using electron-packager!
This process served me well, after figuring out how to set up my directories (and deal with some annoying issues with the Steam overlay).1
Rust again?
For sure. I really appreciate the benefits of having a strongly and statically typed language backing my game logic. Although getting comfortable with its memoray management model is difficult, it is an incredibly useful tool to avoid one of the most primitive causes of game bugs and crashes.
I would recommend a hybrid approach whereby you use another simpler tool like p5.js to draft your prototypes, and then bring them to production using Rust. Otherwise, Rust will hinder your ability to prototype efficiently with its strictness.
Meta-components
I wanted to spare a little space for some of the challenging technical problems I encountered during the game’s development, and I really wish to make dedicated posts for all of these, so check my blog out every now and then!
I wanted to spare a little space for some of the challenging technical problems I encountered during the game’s development, and I really wish to make dedicated posts for all of these, so check my blog out every now and then!
Online multiplayer in deterministic games
Sychronising your game’s state is rather straightforwad if your game is deterministic. Taking chess as an example, having the standard chess notation for the game allows you to replicate the state of the game at any given point, because each series of turns from the same starting point is guaranteed to lead to the same outcome of the board (and therefore the state of the game).
Maginet has a few more stateful components, such as the health of each individual mage, or powerups on the board. However, each action taken on the same state will always lead to the same outcome of the game, because there is no randomness involved.
In turn, if you design your game to either have predictable randomness (seeding your random number generator and pulling numbers from it in a deterministic order) will give you the advantage of only having to communicate the turns that the players take.
You can even rewind the game by just replaying it (which is what I use for the ‘undo’ functionality):
/// Rewinds the [`Game`] by `delta` turns.
/// Works via replicating the game from the initial [`Level`] with its [`Turn`] history.
pub fn rewind(&self, delta: usize) -> Game {
let mut rewinded_game = Game::new(&self.level_prototype, self.can_stalemate).unwrap();
let turn_toward = self.turns().saturating_sub(delta);
for Turn(from, to) in self.turns.iter().take(turn_toward) {
rewinded_game.take_move(*from, *to);
}
rewinded_game
}
Maginet’s networking stack is very simple. Players join a lobby, and after they are confirmed to have joined, they simply poll the server for the turns which happened after their current game state. Whenever a new turn is (or turns are) returned, the game executes it and in theory, the states between players are synchronised.
This method served me well, and because there are no timing concerns, players can poll the server just once a second and still be reasonably scheduled in game state or time.
The problem of synchronisation for multiplayer games becomes a lot more challening when you have to do it 2-to-60 times per second, and that is something that still boggles my mind (and is a bit of a challenge for the next game I’m working on).
AI (…or ye olde tree search)
I used the most basic chess programming technique, which is tree-searching at any given point for the next X number of turns. This is very straightforward, and fast enough that for moderately complex boards (4-versus-4 mages, for example) that it can be performed in a single frame (which is around 16 milliseconds for your average 60Hz display).
The method I used is alpha-beta pruning, which in essence avoids computing board state for moves your enemy wouldn’t bother making as it puts them in a worse situation. With a sprinkled bit of randomness to divert the enemy from always making the best moves, engaging with the AI becomes quite enjoyable.
You can see in the GIF above a case where two mages are at an impasse, they cannot reach each other, so they just waste turns, avoiding making a mistake. I encountered this and many other emergent behaviour from watching AI games. It was a big driver in me making this game, and seeing it come live was truly amazing!
Before you leave!
Maginet is available now on Steam! The Steam version comes with online multiplayer support and 28 campaign levels to try out against the AI player. A demo is playable at maginet.evrim.zone or at its itch.io page.
If you liked this post and the game looks cool to you then check it out. I would be very happy to hear your feedback!
I am also collection emails to start a newsletter later on about Maginet and other projects, so drop by your email if you would like to hear more:
-
You must use
--in-process-gpu --disable-direct-composition
if you want your Electron app to interact correct with Steam. It might still cause issues (like it does with Maginet) because the browser engine will not redraw parts of the screen which are static. This leads to some odd graphical glitches where traces of the Steam overview will remain on screen. I tried to avert this, but to little avail. ↩︎