Flashlight | Perf comparison

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:

  1. create a stream

    1.1. from an event

    1.2. from a timer

  2. apply filtering over a stream

  3. map over a stream

  4. merge two streams

  5. 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.

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;
    }
}