DEV Community

Cover image for Building a Browser-Based RPG Map Editor with Rust, WebAssembly, WebGL2, and React
TheXper
TheXper

Posted on

Building a Browser-Based RPG Map Editor with Rust, WebAssembly, WebGL2, and React

I've been building RPGMapEditor.com — a browser-based fantasy map editor for dungeon masters, worldbuilders, and tabletop RPG players.

The stack is: Rust + WebAssembly for the editor core, WebGL2 for rendering, React + TypeScript for UI, Rocket for the backend, and SQLite for storage.

This post is not a product pitch. It's about the architecture decisions I made, what broke, what I'd change, and why a map editor is a surprisingly brutal problem domain.


Quick answers

What is RPGMapEditor.com?
A browser-based fantasy map editor for tabletop RPG creators, dungeon masters, worldbuilders, and virtual tabletop users. No install required.

Why Rust and WebAssembly in a browser app?
Rust/WASM keeps editor state, geometry, layer data, and commands outside of React. React owns the UI. Rust owns the map. They don't fight over the source of truth.

Is it an Inkarnate alternative?
It is in the same category as Inkarnate and Dungeondraft — fantasy maps, battle maps, dungeon maps — but built with a Rust/WASM/WebGL2 browser-native architecture instead of Unity or a pure JS canvas.

Who is it for?
Dungeon masters, tabletop RPG players, worldbuilders, indie game developers, and creators who need fantasy maps and VTT-ready exports.


The hard part

A map editor is not a normal web app.

The login page and dashboard were easy. The hard parts were:

  • Keeping editor state deterministic across undo/redo
  • Rendering hundreds of stamps without one draw call per object
  • Preventing React state from becoming the source of truth for the map
  • Keeping saves structured rather than flattening everything into a PNG
  • Supporting layers, procedural brushes, terrain, fog, and export without turning the codebase into spaghetti

Every time I tried to prototype a map editor in pure React with a canvas, it collapsed. State was in three places. Undo was wrong. Re-renders were killing rendering performance. The map and the UI were constantly fighting over who owned what.

That's why I moved the editor core to Rust/WASM.


Architecture

User input (mouse, keyboard, touch)
        ↓
React UI — toolbar, panels, modals
        ↓
TypeScript ↔ WASM bindings (wasm-bindgen)
        ↓
Rust editor engine
        ↓
Command system + immutable map state
        ↓
Renderer pipeline
        ↓
WebGL2 — batched draw calls, atlas, framebuffers
        ↓
<canvas>
Enter fullscreen mode Exit fullscreen mode

React handles what the user sees and clicks. Rust handles what the map actually is. They communicate through typed WASM bindings. The Rust side is the single source of truth. React is display.


Why Rust instead of TypeScript for the core

I want to be honest: Rust has a steep learning curve, and I'm not a graphics programming expert. I picked it anyway because:

  1. Ownership model forces you to think about state clearly. You can't have two mutable references to the same map state. That prevents a whole class of bugs that destroyed my earlier Canvas/JS prototypes.

  2. WASM performance is real. Geometry operations, tessellation with lyon_tessellation, and texture atlas packing with guillotiere are fast and deterministic. No GC pauses, no hidden re-allocation surprises.

  3. The command system is cleaner in Rust. Every editor action — stamp placement, eraser stroke, layer reorder, terrain paint — is a typed Command enum. Undo/redo is just a stack of commands. This is theoretically possible in TypeScript but Rust's type system makes it much harder to cheat.


The rendering pipeline

WebGL2 was the right call for this kind of editor. The key constraint: minimizing draw calls.

A naive implementation draws one quad per stamp. Drop 200 stamps on a map and you have 200 draw calls. That doesn't scale.

Instead:

  • All stamp textures are packed into a texture atlas at load time using guillotiere on the Rust side.
  • The renderer batches sprites that share the same atlas texture into a single draw call.
  • Each layer has its own framebuffer. Compositing layers means blending a small number of textures rather than re-drawing every object.
  • The atlas UV coordinates are pre-calculated in Rust and passed to WebGL2 as typed arrays.

The result: a map with a few hundred stamps and four layers renders comfortably at 60fps on mid-range hardware.


What broke (and what I'd redesign)

Biggest mistake: underestimating wasm-bindgen boilerplate.

The boundary between Rust and TypeScript takes serious maintenance. Every time I changed a Rust type that crossed the boundary, I had to update bindings, types, and sometimes the React component that consumed them. I should have designed a stable, narrow API surface between the two sides much earlier. Instead, the boundary grew organically and became a source of bugs.

Second mistake: starting the atlas too simple.

My first texture atlas was a fixed 2048×2048 texture. That ran out quickly once I added procedural terrain brushes. Moving to a dynamic atlas system (guillotiere) mid-project was painful. I should have planned for this from the start.

Third mistake: saving too late.

I spent months on the rendering pipeline before I built the save format. When I finally sat down to serialize a map, I realized my state structure wasn't as clean as I thought. Parts of the render state had leaked into the map state. Redesigning the save format forced a partial refactor of the state model.

If I started over: design the save format on day one. It forces you to define what the map actually is, separate from how it renders.


A real code snippet: the command system

Here's a simplified version of how editor commands work in Rust. Every user action that modifies the map becomes a Command:

pub enum Command {
    PlaceStamp {
        stamp_id: StampId,
        position: Vec2,
        layer: LayerId,
        scale: f32,
        rotation: f32,
    },
    EraseArea {
        region: Rect,
        layer: LayerId,
    },
    MoveStamp {
        stamp_id: StampId,
        from: Vec2,
        to: Vec2,
    },
    ReorderLayer {
        layer_id: LayerId,
        new_index: usize,
    },
}

pub struct EditorState {
    map: MapState,
    undo_stack: Vec<Command>,
    redo_stack: Vec<Command>,
}

impl EditorState {
    pub fn apply(&mut self, cmd: Command) {
        self.map.execute(&cmd);
        self.undo_stack.push(cmd);
        self.redo_stack.clear();
    }

    pub fn undo(&mut self) {
        if let Some(cmd) = self.undo_stack.pop() {
            self.map.revert(&cmd);
            self.redo_stack.push(cmd);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The key: revert is the inverse of execute. Every command knows how to undo itself. No magic. No diff-based snapshots. No full state cloning on every action.


Current status


The editor is in active development. Working right now:

  • Stamp placement, eraser, selection tools
  • Layer system with blend modes
  • Procedural terrain brushes (noise-based)
  • Texture atlas and batched rendering
  • Fog of War
  • JSON-based save format
  • PNG export

In progress:

  • VTT export format (Foundry, Roll20 compatibility)
  • Lighting effects with framebuffer compositing
  • Collaborative editing (long-term goal)

Is this approach overkill for a side project?

Probably, yes.

A simpler implementation with Fabric.js or Konva would have gotten me a working editor faster. If the goal was just shipping a demo, I over-engineered this. hah

But I've used those approaches before. They hit walls. When you want reliable undo/redo, proper layer compositing, atlas batching, and a structured save format, the simple canvas library starts fighting you. You end up bolting architecture onto a foundation that wasn't designed for it.

Building it as an architecture problem from the start has made it easier to add features correctly, even if it made the first six months slower.


If you're building something similar

A few things I'd tell myself earlier:

  1. Design your save format first. It forces clarity on what your data model actually is.
  2. Pick one source of truth and protect it. If Rust owns the map, don't let React sneak in and mutate it directly.
  3. Design a narrow, stable WASM API surface. Fewer crossing points = fewer headaches.
  4. Don't optimize the renderer until you have a correct renderer. Get batching right logically before profiling.
  5. Log your draw call count during development. It's the fastest feedback loop for renderer health.

If you're working on browser-based creative tools, game-adjacent editors, or WebAssembly/WebGL projects, I'd genuinely enjoy discussing the architecture. Drop a comment or find me at RPGMapEditor.com.

Top comments (0)