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
┌────────────┐
│ │
┌─────▶│Shadowcaster│
│ │ │
│ └────────────┘
┌─────────┐ ┌──────────┐ │ visibility
│ │ │ │ │
│ Rex ├─────▶│Flashlight│──────┤
│ │ │ │ │
└─────────┘ └──────────┘ │ pathfinding
Javascript Rust │ ┌────────────┐
entrypoint │ │ │
└─────▶│ Pathfinder │
│ │
└────────────┘
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
// Emit a tuple of values every 500 millisecond.
// Log 5 times and then unsubscribe.
//
// - `Stream.fromInterval(500)` creates a stream that emits a value every 500 milliseconds.
// - `Stream.fromInterval(1000)` creates a stream that emits a value every 1 second.
// - withLatestFrom combines both the Streams.
const halfSecStream$ = Stream.fromInterval(500);
const mixedTimerStream$ = Stream.fromInterval(1000).withLatestFrom(
halfSecStream$,
);
mixedTimerStream$.subscribe({
next: ([halfSecTick, oneSecTick]) => {
console.log(halfSecTick, oneSecTick);
// unsubscribe after logging 5 times
if (halfSecTick >= 5) mixedTimerStream$.unsubscribe?.();
},
complete: () => {
console.log("stream concluded");
},
});
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.
// Rust
pub enum TileType {
Transparent,
Opaque,
}
pub struct TileGrid {
pub tiles: Vec<TileType>,
..
}
fn compute_visible_tiles(&mut self, world: &TileGrid) -> HashMap<IVec2, i32> { ... }
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.
// Rust
fn find_path(
starting_map: &Vec<Glyph>,
width: u8,
from_glyph: Glyph,
to_glyph: Glyph,
) -> Vec<Move> { ... }
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 TUI interface etc.
This brings be 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.
// Typescript
// 1.1. create a stream from an event
const keyDownInput$ = Stream.fromEvent("keydown", document.body);
// 2. apply filtering to a stream
// filtered stream only contains instances of `f` keydown events
const fKeyInput$ = keyUpInput$.filter((e) => keyIsF(e));
const flashlightButtonClick$ = Stream.fromEvent(
"click",
document.getElementById("flashlight"),
);
flashlightButtonClick$
// 4. merge two streams
.withLatestFrom(fKeyInput$)
// only continue if the flashlight is turned off
.filter(() => !playerState.isFlashlightOn)
// 3. map over a stream
.map(([e1, e2]) => {
e1?.preventDefault();
e2?.preventDefault();
// update state
return { value: playerState.isFlashlightOn };
})
.subscribe({
next: ({ value }) => {
// 5. cause side effects
toggleFlashlight({ value });
},
complete: () => {},
});
Rendering
This is a version of the render function, simplified for illustration.
// Typescript
export const render = () => {
// this computes the visibility of each time from the player's POV
if (playerState.isFlashlightOn) engine.compute_visibility();
// UInt8Array converted to an array of glyph indices
const mapState = Array.from(engine.get_map_state());
// render target
const mapGridContainer = document.getElementById("grid-container");
// render each tile as a div
arr.forEach((glyphIdx, cellIdx) => {
const cell = document.createElement("div");
const cellVisibility = visibility[cellIdx];
// set luminance value based on calculated visibility for the tile
cell.style.backgroundColor = `hsla(44, 96, ${cellVisibility}, 100)`;
// ['+', ' ', '♣', ' ', '.', '@', 'G', 'g']
cell.textContent = GLYPHS[glyphIdx];
mapGridContainer.appendChild(cell);
});
};
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.
// Rust
impl Flashlight {
...
pub fn compute_visibility(&mut self) {
// shadowcaster only cares if a tile is opaque or transparent
// so I map each Glyph to the appropriate tile type
let tiles = self
.map_state
.iter()
.map(|glyph| match glyph {
| Glyph::Monster
| Glyph::Player
| Glyph::Floor => TileType::Transparent,
Glyph::Target | Glyph::Tree => TileType::Opaque,
})
.collect();
let mut visibility = Visibility { ... };
visibility.observer = IVec2 {
// set the player position as the observer
};
let visible_tiles_hashmap = visibility.compute_visible_tiles(&TileGrid {
tiles,
...
});
// store the visibility on the main struct
self.visibility_state = visible_tiles_hashmap;
}
}