Factini is a Rust-to-WASM driven html canvas game. The whole game lives in canvas, including the UI. So what does the architecture look like? In this post I'm going to dig into that!
You can
play the game here. (It's free and runs in your browser!)
It is actually the second project I've done in Rust, so it's really still very much a learning project. I think I was mostly looking for a bigger project. I wasn't really planning to work on it for the next year but here we are. It being a learner project also means that the architecture is not going to be superb and certainly not going to qualify as "dogmatic Rust". And please keep in mind, this is just a hobby project. Consider yourself warned :)
The global objects
To kick that off: I've used the approach of "god objects". This was a tip
Rik gave me when I started in Rust and struggled a bit with
the read/write constraints of the language.
The
Wikipedia article on "god object" will tell you that "The god object is an example of an anti-pattern and a code smell.". So there's that. Not sure if that counts for Rust, but they
are still global objects and that's always a bit questionable.
Either way, I juggle a few of these objects around and made it a pattern in most of the functions that use them:
options
,
state
,
config
,
factory
,
mouse_state
, and
button_canvii
. They only get
mut
when necessary although I'm not sure if that truly changes anything (memory, perf, whatever).
With this approach you kind-of circumvent most of the pain points of dealing with slices (references) and mutability. These problems become much more local to the current function. You still define them explicitly in a structure and for an app like this, it kind of makes sense to me.
Options, state, config
The set of these three objects contain the main global state of the app.
You would ever only have one instance of these objects in your app. Although I suppose the
options
and
state
might be more tightly bound to a
factory
instance. Then again there's only one UI so I'm not even sure how that would actually work. I guess with the current insights I could refactor it to make the distinction clear. Could but won't.
The idea is that
options
contains runtime configurable settings. I think it's mostly debug stuff in here, not that much player facing except for the game speed. But log tracing, debug painting, game/ui speed, input handling, etc. Stuff you can configure from the debug UI goes mostly into the
options
.
The
state
object would be more non-configurable runtime app state, but (mostly) not specific to the game. Like whether the game is still showing a loading screen, fullscreen state, and some other UI state.
Finally,
config
. At app startup a bunch of custom markdown-esque config files are loaded and parsed and their result is stored in this
config
. This includes quest story lines, app asset details, unlock tree, and sprite details. You can configure much of it without requiring a Rust compile at the cost of startup parsing these files. Data in the config shouldn't change after startup (and it doesn't, except when you explicitly change the config through the debug tools).
Factory
Then there's
factory
, containing all the actual game state. Tick count, floor layout, belt states, etc. It's all stored here.
You should be able to create multiple
factory
objects and tick them individually, then
at least switch between which factory to actually paint. Some
state
is still shared (probably a smell that it should go into
factory
rather than
state
) so I'm not sure if that would actually work. But that was the idea :)
Input
Input is mostly stored in
mouse_state
, which is a misnomer because it holds touch state too, but feh.
Things like the last coordinate of a mouse/touch move/up/down action and which interactive elements or floor coordinates this affects, if any. This state is mostly used for painting the frame because hitbox tracking is only applied once per frame. Otherwise for every of these cases you would need to do "am I hit" check, which might be expensive in aggregate.
So instead I chunked the game in nine zones and I know to which zone each interactive element belongs. For hitbox checks, when processing input, I first check the zone of the coordinate and then only do hitbox checks for interactive elements inside that zone. I store the result (if any) and at paint time simply read booleans or enums which tell me whether an interactive element is by hover/down/up.
For the "floor" (where you place belts and machines) it simply remembers the coordinates. Then when the floor is painted, for each cell it checks whether the current coordinate is any of the hover/down/up coordinates and paints accordingly. You could chunk this down even further but considering the floor has 15x15 + an edge, the total number of checks this generates is small.
Button assets
Lastly there's a
button_canvii
object which contains offscreen canvas elements where buttons have been pre-rendered. The actual buttons are "nine-sliced", meaning I have each side, corner, and center, and at startup I render the buttons at the sizes that I want them. This way you don't have to render each part individually for each frame but rather just copy these.
Arguably you could render these buttons at compile time but that would still require some manual actions (ok I'm sure you can automate it but that wasn't worth the effort). The overhead for pre-painting these buttons is hardly observable.
The canvas elements for all the configurable assets are stored in
config.sprite_cache_canvas
. Each asset will reference a canvas element in that vector indirectly.
Main phases
You could say there are four phases to the game: startup, loading, waiting, and loops.
In this context, "waiting" means waiting for the player to press "start" and "loops" refers to the main game, input, and paint loops.
Startup phase
Pre paint loop there are a few things to setup and create.
- Main canvas element is setup and added to the DOM
- Options and state objects are created
- Configuration files are requested from the JS world, parsed, and processed
- Images are loaded based on the config files
- Event handlers are created for mouse, pointers, and resize
- Last known map/game progress is restored and a
factory
state is created accordingly
- Quick save maps are restored
- Main
requestAnimationFrame
("
raF") loop is started
requestAnimationFrame
The other phases are intertwined in the raF loop:
- Check fps rate
- Apply any scheduled actions (triggered from UI or JS) like loading a map, options, or changing the factory layout
- "
Main game loop". If not loading/waiting, run a number of game ticks if the game is not still loading
- "
Main input loop". Includes applying triggered actions like hover and clicks, for insofar they don't need to be scheduled (like map loads)
- Handle changes to the factory layout. Changes can come from UI interaction or actions triggered from JS-land (like clipboard paste). This will try to apply changes to belts or machines, automatically determine ports based on the new layout, collect all required machines on the floor, and basically "recompile" the factory state such that we can do fast reads while painting and doing future factory ticks
- "
Main paint loop", actually render the frame
web_sys
In Rust there's a "crate" (library/package) called "web_sys" and it basically exposes all the API's from the web platform. So you can create elements, manipulate the DOM, wire up events, and also do all the canvas operations.
The library seems pretty solid. Functions and properties are exposed as methods and overloaded JS methods are exposed explicitly with the argument type. You have to manually "include" parts of the library when you need them.
The docs will say "requires this or that feature" but the error code just says "unknown method" or whatever. But once you get used to that you'll quickly realize that certain platform features will require one or more web_sys features enabled too. No big deal.
Basically, if you're used to the web platform then driving it from Rust makes it real easy for you to drive it through WASM instead.
I have a few helper methods because otherwise you end up doing
.expect()
all over the place. Well. You'll still be doing that, but less.
pub fn window() -> web_sys::Window {
return web_sys::window().expect("no global `window` exists");
}
pub fn document() -> web_sys::Document {
return window()
.document()
.expect("should have a document on window");
// You can also have it return an HtmlDocument, which is different (richer) from Document. Requires HtmlDocument feature in cargo.toml
// .dyn_into::<web_sys::HtmlDocument>().unwrap()
}
pub fn body() -> web_sys::HtmlElement {
return document().body().expect("document should have a body");
}
And at the top of my (UI) code I create references which won't ever change.
let window = window();
let document = document();
let body = body();
It seems Rust is a heavy proponent of redeclaring / shadowed variables. Considering this is considered a dark sin in JS it kind of feels like cheating. But you get used to it :)
Main canvas setup
This is how you create the canvas. You'll recognize the parallels to JS code:
let document = document();
let canvas = document.create_element("canvas")?.dyn_into::<web_sys::HtmlCanvasElement>()?;
document.get_element_by_id("main_game").unwrap().append_child(&canvas)?;
canvas.set_id("main_game_canvas");
canvas.set_width(CANVAS_PIXEL_INITIAL_WIDTH as u32);
canvas.set_height(CANVAS_PIXEL_INITIAL_HEIGHT as u32);
// canvas.style().set_property("border", "solid")?;
canvas.style().set_property("width", format!("{}px", CANVAS_CSS_INITIAL_WIDTH as u32).as_str())?;
canvas.style().set_property("height", format!("{}px", CANVAS_CSS_INITIAL_HEIGHT as u32).as_str())?;
canvas.style().set_property("background-image", "url(./img/sand.png)").expect("should work");
// This makes scaling look a lot better in our pixel art game
canvas.style().set_property("image-rendering", "pixelated").expect("should work");
And now we have a canvas with given size, a (CSS) background image, and with its scaling algorithm fixed.
Image loading
While I intend to cover the config parsing and setup in a different post, I should cover the image loading here.
After we parse and process all the runtime configs we'll end up with a bunch of asset details which includes images to load. The next goal is to load all these images, while deduping images that are used more than once, like with a sprite map.
An image can have priority. This will influence the order for requesting the browser to load the images. This way we can load the loading screen before anything else since the browser will otherwise cap image downloading at 4 or 6 in parallel.
We create a vector of image placeholders by looping over
images_to_load
and only create
Some()
for images with priority. Next loop we do the opposite to load all the images without priority. This way all images will end up getting loaded.
(We could also sort the vector and then loop it but that's not going to be faster, 2n
vs nlogn
, plus we're talking a few hundred images max here, and only a handful in production.)
let images_to_load = config.sprite_cache_order;
let mut boxed: Vec<Option<web_sys::HtmlImageElement>> =
images_to_load.iter().enumerate().map(|(index, src)| {
if image_loader_prio.contains(&index) {
return Some(load_tile(src.clone().as_str()));
}
return None;
}).collect::<Vec<Option<web_sys::HtmlImageElement>>>();
images_to_load.iter().enumerate().for_each(|(index, src)| {
if boxed[index] == None {
boxed[index] = Some(load_tile(src.clone().as_str()));
}
});
// Now unbox them so we don't have to do the Option unbox dance every time
let images: Vec = boxed.into_iter().filter_map(|e| e).collect();
Feels silly to have to do a
.filter()
just to convert a vector from one type to another but I believe the
filter
is lazy and will melt with the magical
.collect()
method after compilation. But then, why wouldn't
.collect()
just do this :shrug:.
The
load_tile
function looks like this:
fn load_tile(src: &str) -> web_sys::HtmlImageElement {
let img = document()
.create_element("img")
.expect("to work")
.dyn_into::<web_sys::HtmlImageElement>()
.expect("to work");
img.set_src(src);
return img;
}
And the web platform does the rest. We just check the
.complete()
method (a property in JS) in the paint loop.
Rc Cell
Here's something I didn't fully investigate and mostly copied and molded to make it work: the usages of
Rc
and
Cell
.
I think it's like a Pandora's box and you can circumvent references issues with it? I need them to share references in closures for event handlers and
requestAnimationFrame
callbacks.
Here's some variable setup I had to do, the approach was copied from a tutorial template:
let saw_resize_event = Rc::new(Cell::new(true));
let mouse_x = Rc::new(Cell::new(0.0));
let mouse_y = Rc::new(Cell::new(0.0));
let mouse_moved = Rc::new(Cell::new(false));
let last_down_event_type = Rc::new(Cell::new(EventSourceType::Mouse));
let last_mouse_was_down = Rc::new(Cell::new(false));
let last_mouse_down_x = Rc::new(Cell::new(0.0));
let last_mouse_down_y = Rc::new(Cell::new(0.0));
let last_mouse_down_button = Rc::new(Cell::new(0));
let last_mouse_was_up = Rc::new(Cell::new(false));
let last_mouse_up_x = Rc::new(Cell::new(0.0));
let last_mouse_up_y = Rc::new(Cell::new(0.0));
let last_mouse_up_button = Rc::new(Cell::new(0));
let ref_counted_canvas = Rc::new(canvas);
I should have been more explicit with the "mouse_x" names, which is more like "hover_mouse_x" or "last_hover_mouse_x" to be more in line with the others. But oh well.
As you can see there are local variables to hold this state for all the relevant inputs.
Event handlers
For Factini I use a bunch of mouse, touch, and resize event handlers. There are also clipboard events but they had to live in JS land because Rust only supported them with unstable code (and even then it was hard).
This is the
mousedown
handler. I clone all the boxed values and dedicate them to one callback. Multiple callbacks can access and mutate the same box this way without running into Rust's read/write limitation. Again, feels like cheating but I'll take it.
let last_down_event_type = last_down_event_type.clone();
let mouse_x = mouse_x.clone();
let mouse_y = mouse_y.clone();
let last_mouse_was_down = last_mouse_was_down.clone();
let last_mouse_down_x = last_mouse_down_x.clone();
let last_mouse_down_y = last_mouse_down_y.clone();
let last_mouse_down_button = last_mouse_down_button.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
event.stop_propagation();
event.prevent_default();
last_down_event_type.set(EventSourceType::Mouse);
let mx = event.offset_x() as f64;
let my = event.offset_y() as f64;
last_mouse_was_down.set(true);
mouse_x.set(mx);
mouse_y.set(my);
last_mouse_down_x.set(mx);
last_mouse_down_y.set(my);
last_mouse_down_button.set(event.buttons()); // 1=left, 2=right, 3=left-then-also-right (but right-then-also-left is still 2)
}) as Box<dyn FnMut(_)>);
ref_counted_canvas.clone().add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
closure.forget();
The actual closure is created with
Closure::wrap(Box::new(move |event: web_sys::MouseEvent| { .. }) as Box<dyn FnMut(_)>);
, which was again copied from a tutorial. It's then registered like you'd do in JS with
ref_counted_canvas.clone().add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
You have to use the boxed canvas element here otherwise
canvas
will be borrowed forever and I need it later :)
Other events are very similar so I'll omit them here. Or maybe, here's the simplest one: preventing the context (right-mouse) menu:
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
event.stop_propagation();
event.prevent_default();
}) as Box<dyn FnMut(_)>);
ref_counted_canvas.clone().add_event_listener_with_callback("contextmenu", closure.as_ref().unchecked_ref())?;
closure.forget();
Touch events are very similar but you have to jump to some hoops to get the coordinates. That's a web issue by the way, that's not on Rust.
let last_down_event_type = last_down_event_type.clone();
let canvas = ref_counted_canvas.clone();
let mouse_x = mouse_x.clone();
let mouse_y = mouse_y.clone();
let last_mouse_was_down = last_mouse_was_down.clone();
let last_mouse_down_x = last_mouse_down_x.clone();
let last_mouse_down_y = last_mouse_down_y.clone();
let last_mouse_down_button = last_mouse_down_button.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
event.stop_propagation();
event.prevent_default();
last_down_event_type.set(EventSourceType::Touch);
let bound = canvas.get_bounding_client_rect();
let event = event.touches().get(0).unwrap();
let mx = -bound.left() + event.client_x() as f64;
let my = -bound.top() + event.client_y() as f64;
last_mouse_was_down.set(true);
mouse_x.set(mx);
mouse_y.set(my);
last_mouse_down_x.set(mx);
last_mouse_down_y.set(my);
last_mouse_down_button.set(1); // 1=left, 2=right, 3=left-then-also-right (but right-then-also-left is still 2). touch is always 1.
}) as Box<dyn FnMut(_)>);
ref_counted_canvas.clone().add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref())?;
closure.forget();
As you can see the touch events record the coordinates to the same vars as the mouse events. At this point "mouse_x" etc are conflated misnomers but I think these names will be a lot better than "input" so I'm keeping it.
For the resize event I merely set a flag (too) because we'll need to confirm current dimensions at the start of next frame.
That's actually how all these events are set up. They only set a flag and record the last received value. If multiple events happen between two frames, the last one may override the first, so at a low frame rate the game may ignore a click. This is acceptable since the game isn't intended to run at 1fps.
Local storage
I use local storage to save and load game state. Maps are only a few k and so are the settings so it's an easy way of remembering progress.
The code is straightforward:
let local_storage = window().local_storage().unwrap().unwrap();
local_storage.get_item(LOCAL_STORAGE_MAP).unwrap();
I guess accessing local storage (the object itself, not a value) in the browser can throw?
requestAnimationFrame loop
After all said and done we're now ready to start the main game loop and show things to the user.
I won't go into details on the how and why of
requestAnimationFrame
. There are plenty of tutorials out there on the matter. Pick any one of them.
This is the code for the loop:
fn request_animation_frame(f: &Closure<dyn FnMut(f64)>) {
window()
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("should register `requestAnimationFrame` OK");
}
let f = Rc::new(RefCell::new(None));
let g = f.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move |time: f64| {
... // Your logic goes here
}) as Box<dyn FnMut(f64)>));
// Schedule the first frame:
request_animation_frame(g.borrow().as_ref().unwrap());
Note that it's an
f64
because you get the current time in the callback as an
f64
.
Every time you reschedule a frame you call the last line again:
request_animation_frame(f.borrow().as_ref().unwrap());
and that's it.
Again, this part was copied from the tutorial. Assignments to function calls (
*g.borrow_mut() = ...
) break my mental model and while I think I know what's going on there, I haven't actually looked into it.
I think we have to create the
f
to act as a reference holder? And clone it to
g
because ... I dunno. See
the rustwasm reference where I got it from.
One thing to add; apparently you can get ghost frame requests? So this is how to prevent them:
// Before the callback:
let mut last_time: f64 = 0.0;
// Inside the callback:
if last_time == 0.0 || last_time == time {
// Either it's the same frame (can happen) or the first frame. Bail.
last_time = time;
request_animation_frame(f.borrow().as_ref().unwrap());
return;
}
And again, I think this is coming from the web platform and not the Rust side.
FPS counter
It's funny. You would expect an FPS counter to be trivial. But I always find them awkward to create.
This is the FPS code:
let mut fps: VecDeque<f64> = VecDeque::new();
// ...
let min = time - 1000.0; // minus one second
while fps.len() > 0 && fps[0] < min {
fps.pop_front();
}
fps.push_back(time);
We get the current timestamp from the
requestAnimationFrame
callback so we don't have to explicitly reach into JS land for that (not sure if it would matter but this way we simply don't have to).
We compute the timestamp from now to exactly a second ago. We remove any timestamp from the vector that is older than that. What's left is the number of frames we've painted in the past second.
So when painting the number it only has to report
fps.len()
.
Loops
While I don't want to get into too much (more) detail on the game, paint, and input loops, I do want to explain them a little bit.
At the core you have the game loop. Every frame you want to "tick" the game world as much further as time has passed since the last frame. A "tick" in a game means moving the game clock forward by the smallest possible increment.
While technically time should be continuous, like it is in our world, the game is kind of frozen between frames. That's because there's no point in keeping it ticking.
Some philosophical insights: there are some parallels to sleeping or to the question "if a tree falls in the forest and there's nobody around to observe it, does it make a sound?". Good luck.When the
requestAnimationFrame
callback fires the browser wants to paint the canvas and you should be ready to paint the next frame.
If the game is still at the loader screen then this will be handled immediately and the loop is rescheduled. It just shortcuts the rest of the logic.
Loader aside, at the start of each frame the game will first check for any scheduled actions. Either scheduled from within Rust or from JS land. If anything is scheduled, like loading a map or changing a setting, apply it now.
Once there's nothing left being scheduled the game loop starts. It will tick the game world forward a few times. By default the game wants to run 10_000 ticks per second, so that should be about 100 ticks per frame.
If the game is running in CLI mode, just dump the current game state in the CLI. This is basically how I started it and I never bothered to remove this. It's also why the game will poll for input
after applying the game loop.
Ultimately the order feels wrong but since the game should run at 60-100 fps it doesn't really matter because it's not like you were trying to hit a very particular frame. Although you could slow down time to the point where you could see this. So in that case the impact of your input is always shown one frame after the frame before which you applied the action. Don't think you'll notice unless you're trying :)
Once the input is handled the paint loop starts. At this point the game should be immutable and the painting should be an immutable set of steps, although I think there's a few cases where I still mutate some (UI) app state.
After the frame is repainted the loop is rescheduled and it will start again later.
Compilation
One last thing to note is the dev cycle.
The wasm toolchain offers a few binaries to build your code and generate the Rust code. I only really use one. The command to build the code is short:
wasm-pack build --target web
This builds a production build.
To generate a dev build you add
--dev
to that.
wasm-pack build --target web --dev
The code should be the same either way but I did notice some differences. I didn't start using
--dev
until quite a bit into the project. At that point, enabling the flag triggered some OOB check which was very difficult to narrow down. At some point I fixed it and it worked ever since.
Using the
--dev
flag will decrease the compilation step from about 30s to 4s which greatly improves development velocity.
The output files are stored in a
/pkg
folder. The
.js
and
.wasm
files are what you need. And here is some HTML boilerplate to load it:
<script type="module">
// Error.stackTraceLimit = Infinity;
import('../pkg/factini.js').then(x => x.default(), e => console.error(e));
</script>
This will load the script and the wasm file and start the app.
The generated js is about 40kb. The production wasm file weighs about 850kb uncompressed (which is plain text so should compress nicely) while the dev wasm file weighs about 1.1mb uncompressed.
Beyond that, I moved a bunch of configuration data out to a custom configuration that is parsed at app startup. This means you can simply change many parts of the config without having to recompile.
The rest is just vanilla web.
Tests
Ah, we can be brief here: there are no tests. It wasn't meant to be this size of project and testing wasn't an objective so it never really came to pass. Sorry, me in a year or so.
Wrap up
I've tried to explain a bit how the core of Factini works and shown some of the Rust code that drives it.
I ended up spending quite some time on this project. At some point it's less about learning the language and more about getting the project over the finish line. At the end of the day the idea of vanilla coding is similar in any language. You work with the constraints the language gives you and write code that ends up doing what you want it to do.
If I had to change anything, it would probably be to add some tests, make a stricter distinction between the global objects (have a proof of concept of two factories at once), add more macros, split up the
_web.rs
, remove the unused code, make a better proper production build (without debugging elements), invest a bit in the optimization parameters of
wasm-pack
, etc.
But I'm not planning to work on this any further beyond the polish required for shipping it :)
You can find
Factini here.