Crossing the WASM
{{< video src="/flashlight.webm" type="video/webm" >}}
I recently talked to a group of engineers about integrating WASM into Javascript projects. I've integrated some Rust compiled to WASM into my projects a few times for various reasons ranging from performance to FOMO. I wanted to write down the architecture of a tiny game I wrote recently (with vanilla JS, HTML, and WASM), to demonstrate how to integrate very small parts of Rust using WASM into Javascript/TS projects. And eventually let it consume your entire life.
I've been interested in roguelikes since I first saw Gridbugs' Rain Forest and started reading the blog posts. The rabbit hole led me down recursive shadowcasting and the work Roguelike Celebration conference speakers have been creating. I wanted to make something for the web without an engine or external libraries, just for shits and giggles.
This post highlights some aspects of the architecture that I think are most crucial to recreating something like Flashlight without excessive handholding.
The architecture has undergone several small refactors but the following constraints have always guided the changes:
- run in the browser
- fast
- LGTM on mobile
- no libraries
- avoid exploits
Current Architecture
Rex
Usually when I write a new web app I just use React as it's straightforward and I can focus on the app logic. This time I didn't want to bloat the bundle size for something this simple.
Rex is an incredibly thin library for wrangling observable streams. All of the event handling, data manipulation, and visual side effects are handled by it.
It can do a handful of things:
create a stream
1.1. from an event
1.2. from a timer
apply filtering over a stream
map over a stream
merge two streams
cause side effects via stream subscriptions
These are sufficient to build a tiny game like Flashlight.
Entrypoint - Flashlight (crate)
This is the entrypoint into the gameplay logic all of which is compiled to WASM and runs in the browser. The game uses recursive shadowcasting to calculate the visible tiles. Initially I wrote shadowcaster to use on a game I wrote with Bevy (a Rust game engine), so instead of re-implementing shadowcasting in Javascript I build the game around it and used WASM to run it on the browser. Later on in the post you'll see that running the core game logic in WASM provides a gameplay benefit that aligns with the core mechanic.
Flashlight takes care of the following:
- process character movement
- process enemy movement
- get clipped map state from camera
- update map state
- enforce gameplay rules like walkable tiles, consumables, combat etc
Shadowcaster
Flashlight calls into Shadowcaster to calculate the tiles visible to the character at any given time. I won't go much into the details of the implementation other than that it was implemented without looking at existing implementations and was pretty incorrect for a long time. Here are some references that I've used while building it.
- Visible Area Detection with Recursive Shadowcast - Gridbugs
- Roguelike Vision Algorithms - Adam Milazzo
The compute_visible_tiles function does the heavy lifting of finding the visible tiles and returning their positions, and Flashlight then decides how to best use that to update map state.
Pathfinder
This is just a naive breadth-first search implementation that allows the enemy to find the character. I wanted to keep pathfinding in a separate create as I've been using it on other projects that don't share the core gameplay of Flashlight. It merely takes a vector of glyphs, grid width, a starting glyph and an ending glyph, and returns the shortest set of moves between those glyphs. The rules engine then determines what to do with all this data.
Flashlight (crate) contd.
Once the crate has all the information it updates the state and converts it to a JS UInt8Array to communicate the updated map state back to the JS for rendering. As the only way to communicate between the JS context and the WASM boundary is via character move commands and a map state in the form of a UInt8Array there's little room for leaking extra information. This ensures that the player can't just inspect element on the hidden glyphs to find the locations of the enemy and the health pack. Only returning a single array of glyphs that represent each cell on the map has it's drawbacks. The state can only represent full moves, and no sub move data can be represented using the map state. Eg. animation direction of the character when it bumps into an obstacle is kept firmly in the JS land. Visual properties of how each cell is rendered have to be mapped from a single UInt8 (glyph). On one hand it's very limiting but it also means that the frontend can be completely independent. I can write it using canvas, a native gui library, as a terminal UI etc.
This brings me back to Rex and how the glyphs are rendered to the screen (it's just HTML divs).
UI and Ephemeral State Management
Rendering the UI elements and the game is handled via JS functions all of which are orchestrated using Rex.
Any state that doesn't impact the core gameplay stays firmly in the JS land. The state of the UI is stored in a class which keeps track of things like character animation states, in-game dialog, dialog boxes, button states, etc. All of this isn't part of the core gameplay logic (inside flashlight crate) as it can either be derived from the map state or it's too tightly coupled to the visual representation. Particle effect states like position and intensity of rain, damage numbers over the player character and the monster, etc are part of the closure state of their update functions. This data doesn't have gameplay consequences and that's why colocating it inside the core engine has no benefits.
I want to focus on a small section of the game that triggers a state update and renders the map to demonstrate the end to end flow. At the start of the game the user is expected to turn the flashlight on by tapping the button three consecutive times.
Local state update
This is an ever so slightly simplified version of the code that connects events to state updates. The code comments point to the capabilities of Rex mentioned earlier.
Rendering
This is a version of the render function, simplified for illustration.
Once the filter is satisfied the state for flashlight (in-game item) is toggled which results in the compute_visibility function getting invoked in the next render loop. Flashlight uses the shadowcaster crate to get a hashmap of tiles that are visible.
Discussion in the ATmosphere