I wrote a game. In Rust. In html5 canvas through WASM. It's pretty cool and I figured I should write a couple of blog posts about it before releasing it because I tried to have as few dependencies as possible and no unsafe/unstable code etc. But that also means figuring out a few things along the way.
You can
play the game here. (It's free and runs in your browser!)
You can
find the source here.
There are a bunch of more related posts linked at the bottom of this post.
The game
The game is called Factini and it's a tiny belting game. You have a 15x15 grid on which you can place belts and machines and you move parts from one machine to another to build bigger parts. If you've ever played
Factorio (or
Mindustry or any of them), you know the drill. Except Factini is a tiny fraction of what those games do.
The tech
Factini is fully written in Rust (
source). I compile it to WASM so I can run it in the browser. In the browser there's a very thin JS layer to bootstrap the WASM and to support the clipboard access. There's also a fallback function for the vendor prefixed
Fullscreen API, which I'm not sure Rust will do (at least not without
unstable/
unsafe).
On the web side, all of Factini happens completely inside a canvas. The production code literally has some script tags and a canvas tag. This means all the interactions (mouse and touch) are hand rolled. Heck, there's a lot that was hand rolled :)
It runs at 100fps on my machine (desktop, linux, both firefox and chrome). Easily. I can crank up the ticks/second rate a few factors before seeing frame drops. But that's also because there's not
that much that happens inside a tick considering at the core there are only 15x15 tiles of things to update, plus some bookkeeping. Interactions are only handled once per frame. In fact, by default I think it's already running at a higher tick rate than would be required for the same visual outcome (1000 tick/sec).
Both mouse and touch devices are supported. It'll run fine on a mobile device, though it'll be a bit small. After playing
Mindustry for a while I realized that there are some interactions that could be handled better for Factini but I only started playing Mindustry this year and I didn't want to spend more time on rewiring those core mechanics.
web_sys
I do have to call out the main library that I've depended on to use web stuff in Rust. The
web_sys
crate is fairly complete and does a good job at mapping web tech 1:1 to Rust land.
I expect the canvas stuff is painted off-screen within WASM and frames are sent to the canvas only at the time of painting (the WASM<>JS data pipeline is relatively expensive).
I did run into a few hickups. For example, the clipboard API is not supported in stable tech. The (stable?) fullscreen API does not appear to attempt vendor prefixes. That sort of thing.
You're also fully dependent on what the library exposes. So you can't just access some arbitrary new and/or experimental tech on
window
like you can in JS.
One other limitation that was a bit annoying is the apparent inability (or very difficult?) to do canvas pixel manipulation. You can read them but not write them. This apparently has to do with memory sharing and the way Rust works. I ultimately gave up trying to get that to work and worked around it.
The motive
I started writing this game to get better at Rust. And because I just wanted to play with a belting game like this.
I did for a time consider whether to publish this as some kind of paid game. But ultimately don't think this game would have significant income potential without significant further time investment and even then it's questionable. That trade-of is currently not worth the risk. And that's fine; I did not need this to make money. I wanted to have fun.
Once I release it I'll also publish the code on GitHub and who knows, maybe some talented artist skins it to something prettier :)
(
edit: code has since been published on GitHub)
The time
The first commit is from May 2022. I've worked on it off-and-on for a year before getting a bit burned out on it. And then I picked it up at the start of this year (2024) to polish what was there and get it over the finish line.
The burnout was two-fold: lack of artist and lack of inspiration which was blocking progress.
As a result, there's no
real end goal to the game. I started writing this maze thingie which looks cute but could not think of a reasonable game goal for it.
The art work
Initially I found a few random tile sets online but nothing I found was what I was looking for in terms of a belting game. So while I was working with placeholder art I found and paid an artist to create a bunch of tiles for me.
This got me the basis for a few things which I could extrapolate to completion. I spent a lot of time in
GIMP (image editor) for this :)
For example, I got one tile of each variation of the belt and created all the other rotations and mirrors. They created an example animation for the belt which I could apply to all the variations.
becomes
Another example are the buckets. While I would have loved to get a full set of actual items, it was clear that this was not going to happen. So ultimately I went with the paint can idea. They supplied me an image of the paint can and I used GIMP to create all the other variations.
Other examples of their art are the main loading screen, the logo, and some (but not all) of the buttons. I guess all the more complex looking button art is theirs :p
To be fair, while I was developing the game looked a lot better with the set of placeholder items, rather than the same image reskinned with different colors. But the good news is that the game is highly moddable so have a go if you can!
There's also some art that I would have loved to seen happen but didn't. For example, the machines you place are just an orange background. That could have been something pretty. I also would have loved to see a concrete background and factory backdrop rather than what it is now.
AI
I tried using some AI for this but it was a bit disappointing. Doesn't help that I didn't want to pay for any of the services so there's that.
I thought of
setting up a local stable diffusion instance but haven't gotten around to it. Also because the yield was questionable.
As it stands, there's one item in the game that was created by chatGPT (the gold badge). There's also a development item "trash" which it generated. It actually looks great but the item is not part of the game.
The orange "factory" icon at the bottom is also based on something an AI generated, a long time ago.
The challenges
I think the biggest challenges I've faced in this project were inspiration, not having an artist with enough time, and Rust not supporting certain web features (like clipboard).
Of course I handicapped myself by setting certain design goals.
Fast
I wanted the game to be fast. I wanted to see what Rust could do with a game like this.
I think that ended up pretty good. There's a lot going on in the screen. And even if you consider that the game tick doesn't have to do that much (in the grand scheme of things), it still has to all happen in a coherent manner and 1000x per second by default.
It's also painting 100 frames per second, which gives it ample time to put it all on the canvas.
To achieve this I did make some conscious decisions around how the game tick would work, separating UI processing from world ticks, limiting direct JS access, preloading images, etc.
There's definitely more performance to be squeezed out if I wanted to, though. Especially certain images could be pre-rendered more aggressively. The backdrop could be pre-rendered on a second canvas (and even put as the background).
One thing I wonder is whether it would be more efficient to loop over all the parts in flight and handle them as individual particles. Right now the belt cells do all the handling, meaning that I have at most 15x15=225 particle ticks to bother with. But on average you have far fewer belt items and even fewer parts-in-flight to tick. Note that it wouldn't matter for the painting side of things, because that happens separately and is based on a static read of state.
A downside of the web is that you don't truly control the fps. For example, on an ipad the fps would not go above 30. On my laptop on battery, the browser would cap it at 60fps. This is the downside of
requestAnimationFrame
. And not something you could easily or reliably improve with
setTimeout
either. Plus, that has a bunch of other downsides. Perhaps at some point you could request/"force" a certain fps. But that has problems too, like rogue websites draining your battery etc.
One other thing I did not even begin to attempt is partial rendering. As it stands, the game will paint the entire canvas from scratch for every frame. But arguably only the floor is fluid. Everything around it is relatively static. And you could use layers to improve that (at the cost of transparency, so hard to predict). If painting performance was an issue I would probably look at dirty rectangle rendering first.
No text
Since everything is canvas driven and I did not want to have to do deal with keyboard inputs I figured I would stay away from anything that required you to write any text. I think this would mostly concern save games anyways.
This idea then evolved into a goal of having as little text as possible. The game would have to be intuitive enough to play without textual explanation. This would also mean the game is language agnostic.
Ironically, the only place where you really see text is the main screen ("start" and "reset"). The rest of the game is icon / image driven.
I went with a parody IKEA manual to give the player some guidance. And I've added an "AI" button which would show the user how to get started. I'm not sure if the manual truly hits the mark but hopefully it'll at least get some laughs :)
Kid friendly
I wanted my kids to be able to play this game. While developing, the kids were between four and eight years old.
I did not want the game to have any violence. No fighting, no war, no enemies. This was fine but it leaves the game end goal kind of questionable. Even Factorio fought against an unknown alien race and half the time you're building stuff just to be able to protect yourself.
Not having this "survival" gimmick kind of limited my options. Or perhaps that just shows my limited view of designing game goals ;) Either way, I stuck to that. No violence. No weapons to craft. No enemies to worry about.
Moddable
Since Rust is a compiled language you have to supply everything ahead of time. For development that's a bit annoying (although that got a lot better once I started using
--dev
and dropped compile time down from 30s so 4s) but to quickly test changes nothing beats the "edit and refresh" of vanilla web. Also, at least when I started this, cargo did not support a proper "watch" mode.
Additionally it quickly became apparent I needed a simple way of maintaining all configuration of the game assets. And not in actual code.
So I wrote a parser for a custom "markdown-esque" syntax through which all the assets could be configured. The amount of control it gives you is still relatively basic. Like you can define the asset information. The url, the sprite frame offsets and size, but also things like frame index, frame looping, frame delay, etc. Everything you need to create a simple sprite sheet based animated asset.
The definitions can "overload", meaning the last value wins. It's permissive like that but you'll crash the whole game if the syntax goes off the rails. This is because Rust does not support a generic try/catch and by the time I realized that I had already put
.expect()
all over the place and did not want to bother changing that. At least the crash happens at startup, albeit with a slightly cryptic error message.
These definitions are loaded at startup and obviously add some delay but I don't think you can notice it. Parsing is byte-by-byte with a very simple (implied) grammar. This part does not worry me.
In dev mode I used
Mermaid to draw the quest tech tree and show progress. That's helpful since the game itself has no real UI view for the tech tree.
So using these configs you can reskin most of the game. Obviously it controls the part assets and storyline tech tree, but also the buttons, the backdrop, and pretty much every image in the game. It's just their positioning that is hardcoded in Rust code. And hey, if there's a request I could even fairly easily add an offset (abs or rel) to the config to allow you to control positioning through this config file too. lmk!
Fully canvas
While
web_sys
gives you access to the whole DOM, I wanted to stick to canvas.
While obvious for graphics, this also means having to do all the interactions yourself. Browsers can't help you there.
And let me tell you: browsers do a lot for you. It's no news for me, I've done this before, but it's quite some work to deal with the various interactions like clicking, dragging, and even hover. You have to do bookkeeping for every button, every interactable pixel, every part of the game world. You have to track dragging. And you have to deal with the fact that the lowest level access to this data are browser events.
There was one other quirk that was new to me; turns out the canvas scales implicitly when used in the fullscreen api. The scale can't be read and you have to infer it from a combination of "is the canvas in fullscreen mode" and then checking the width/height of the window. Oof.
One downside of canvas is performance and quality implications of painting at fractions. For example, a well known gotcha is painting a line at integer pixel offsets versus at half pixels. Another is scaling problems, even when requesting the right scaling kind. I don't think this game is painting enough to make it very relevant for performance but you can definitely see quality problems when doing it wrong.
I think I could do better in pre-scaling certain images to catch this but then what happens when you go fullscreen. I suppose you could introduce an extra set of "large" images ("hi res") to support fullscreen better but since you can't predict the screen size you'll always have to deal with resize artifacts. And without pixel manipulation access you're left at the mercy of the browser's scaling algorithm. Spoiler: they have no mercy.
safe and stable
For reasons I can't explain I tried very hard to not use any features from Rust that required
unsafe"unsafe" or
"unstable".
I dunno. It just felt better that way.
I don't remember concrete things where this caused problems. I think there were a few code constructs that couldn't be done without allowing "unsafe". And there web features that would require "unstable" if you wanted them.
In the end I was able to get here without unsafe and unstable features. So that's pretty cool. I guess :)
The Rust
So what about the core goal of it: do I know Rust now?
Well. A bit.
I would say I'm more comfortable in many parts of the core language. But there's also clearly a bunch of things I've avoided or copy/pasted without fully understanding them (hello
*g.borrow_mut() = Some(Closure::wrap(Box::new(move |time: f64| {
wat?!).
Somebody advised me to use "god objects" (a pseudo-global object with all state that you juggle around) as a simple way of avoiding the paradigm limitations of the language creating read and write access. That worked well for me but I fear it also limits me a little bit in understanding dogmatic Rust. I need to start reading a proper book on it :)
In the game I juggle about five state objects around. Options (configurable at runtime). State (non-configurable app state). Config (static at build/compile/init time). MouseState (obviously containing interaction state). And Factory (game state). There's also a set of canvas elements ("canvii") which contains cached images.
Almost all main functions accept the three or four objects (options, state, config, factory) and as the functions get "further removed" from the main game loop, they tend to require fewer of these objects.
Like I said, this model works for me and I understand what I should and shouldn't do with them.
That's not to say I'll still fumble a bit with strings (
&str
versus
String
) or references.
I feel like I'm cheating with references by using primitives (what does it actually mean to pass in a
&f64
, anyways) and will arbitrarily use
&
and
*
assuming the primitives are copied on stack for "free".
At the same time, any complex struct explicitly does not get the copy/clone trait because I do not want to accidentally introduce copy-move when I don't expect it. If you give it the copy/clone trait it may implicitly copy the reference when it would otherwise move.
I'm also very freely using
f64
for many things and convert between
usize
and
f64
a lot. Not entirely sure what the perf implication is for doing so (clearly not massive) or if there is any at all in WASM land. But if I was micro optimizing then in many cases I would not need an
f64
and maybe not even a float at all and would pick a more suitable size.
There's also many language features that I just don't use. Some were dodged, like the class system. I tried that initially but then ran into typing problems and threw it all out in favor of flat simple structs.
I applied the
PartialEq
trait to most enums so I could do simple
item == ItemEnum::Xyz
to dodge verbose
match item {}
constructs. I don't mind
match
but not for all the things. Not sure if this is a "bad practice".
Oh and for
web_sys
, many of even the most trivial canvas operations have a
Result
. So I splash
.expect("it to work")
all over the place. Also not sure if there are real performance implications for this though I'm not sure if you have a choice anyways (aside from not trapping them, of course).
The end
Okay I feel I've written enough for this general overview.
The
plan is to write a bunch of posts to expand on certain topics for the game. In no particular order:
-
internals- paint loop
-
sprite animations- animation engine
- custom config md
- sprite map generator
- belt entity
- clipboard access
- map serialization as state
- truck animations
-
xorshift for rng- options, state, config, factory god objects
- bouncer animation
- auto builder
- zones interaction
- manual ui handling
- canvas in fullscreen
-
log!()
macroBut I'm not going to promise anything here. It's just a plan. These posts are very time consuming to create and I'm not very sure if there's anyone who actually reads them fully.
I really enjoyed writing this game. Even despite getting burned out on it for a bit. It was more of a "now what do I do with the maze" and lack of art work to continue to improve.
I like what the game ended up being and I'm not sure I can properly express the time and effort that went into it, considering it turned out to be such a relatively small game.
Anyways. You can
play Factini here. I hope you enjoy it. Let me know what you think :)