DEV Community

Cover image for How I Rebuilt an RPG Map Editor with Rust, React, and WASM
TheXper
TheXper

Posted on

How I Rebuilt an RPG Map Editor with Rust, React, and WASM

Replacing a map editor sounds like a frontend task.

It is not.

A browser-based RPG map editor lives inside a product loop:

  1. Create a map.
  2. Open it in the editor.
  3. Paint, place assets, rename it, and change layers.
  4. Save automatically.
  5. Reopen it later and trust that nothing disappeared.

If that loop is fragile, the editor can have great brushes, nice UI, and a fast canvas, but the product still feels broken.

This post walks through how I replaced a legacy map editor with a Rust/WebAssembly editor, rebuilt the maps dashboard around the new flow, and made cloud autosave safer.

The stack:

  • Rust + Rocket for authenticated routes and map persistence
  • SQLite for map records and saved project snapshots
  • Tera templates for the product shell
  • React + Vite + WebAssembly for the editor UI
  • Rust/WASM + WebGL2 for the map editing engine

The boring parts mattered most: routing, boot state, create flow, revisions, and autosave correctness.

The actual problem

The original request was simple:

Replace the current map editor with a WASM editor.
screenshot here;

the old rpg map editor
But replacing the editor route was only the first layer.

A real D&D map maker or RPG map editor needs more than a canvas. It needs a stable workflow around the canvas. Users do not care that the editor is written in Rust or that the renderer uses WebGL2 if the New map button fails or their work does not save reliably.

So the real goal became:

Make map creation, editor boot, cloud saving, and reopening feel boring and trustworthy.

That meant fixing three areas at the same time:

  • the editor route
  • the maps dashboard
  • the save model

1. Replacing the editor route with the WASM editor

The first step was changing the authenticated editor route so that opening a map loaded the new WebAssembly editor instead of the old editor experience.

The important part was not just serving a new index.html file.

The editor needed account-aware boot data from the server:

{
  "map_id": "map_uuid",
  "title": "Dungeon Entrance",
  "revision": 12,
  "updated_at": "2026-05-25T12:00:00Z",
  "read_only": false,
  "csrf_token": "...",
  "api": {
    "load": "/api/maps/map_uuid",
    "save": "/api/maps/map_uuid"
  }
}
Enter fullscreen mode Exit fullscreen mode

That boot payload became the bridge between the product shell and the editor.

The React/Vite/WASM editor should not guess who the user is, which map is open, whether the map is editable, or where it should save. The server already knows that. The editor should receive it explicitly.

That keeps the editor focused on editing, while the product shell owns authentication, permissions, persistence, and routing.

2. The dashboard had to become part of the editor experience

Once the WASM editor route worked, the maps dashboard became the next bottleneck.

The old dashboard still behaved like a marketing page connected to an older creation wizard. But the user expectation was much simpler:

  1. Click New map.
  2. Create a map record.
  3. Redirect into the editor.

That is the core loop of a browser-based battle map maker. If this flow is not reliable, the product feels unfinished before the editor even loads.

So the dashboard was rebuilt around the editing workflow:

  • map summary cards
  • search
  • sorting
  • recent map cards
  • empty state
  • one primary create action

The dashboard stopped being a passive page and became the starting surface for map work.

3. Why the first New map fix was not enough

The first implementation used JavaScript:

await fetch('/api/maps', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken
  },
  body: JSON.stringify({ title: 'Untitled Map' })
});

window.location.href = `/editor/${createdMap.id}`;
Enter fullscreen mode Exit fullscreen mode

That worked in a smoke test.

Then the user reported that clicking New map did nothing.

That exposed the design flaw: a core product action depended entirely on a client-side event listener.

For something as important as creating a map, that is too fragile. JavaScript can fail because of stale assets, a bad bundle, a browser extension, a hydration issue, or a simple selector mismatch.

The fix was progressive enhancement.

The create action became a real HTML form:

<form method="post" action="/maps/new">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
  <button type="submit">New map</button>
</form>
Enter fullscreen mode Exit fullscreen mode

The server route does the critical work:

POST /maps/new
→ create map record
→ redirect to /editor/<id>
Enter fullscreen mode Exit fullscreen mode

JavaScript can still intercept the submit and use the faster API path when everything is healthy. But the fallback path is now server-owned.

That is the right tradeoff:

  • HTML handles the critical action.
  • JavaScript improves the experience.
  • The server remains the source of truth.

This is the kind of boring engineering that makes a SaaS product feel reliable.

4. Keeping the API contract small

The editor did not need a huge API surface.

The useful contract was small:

GET /api/maps/<id>
PUT /api/maps/<id>
Enter fullscreen mode Exit fullscreen mode

GET /api/maps/<id> loads the current map snapshot.

PUT /api/maps/<id> saves the new title, project JSON, and concurrency metadata.

The client sends something like:

{
  "title": "Dungeon Entrance",
  "snapshot": {
    "version": 1,
    "layers": [],
    "camera": {},
    "objects": []
  },
  "expected_revision": 12,
  "expected_updated_at": "2026-05-25T12:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The server can then reject stale writes instead of silently overwriting newer data.

For a single-user editor, optimistic concurrency may look unnecessary. It is not. Multiple tabs, reloads, slow requests, and old local drafts can all create write conflicts.

The editor should not pretend those cases do not exist.

5. Cloud autosave has to handle real editing

The editor already had local autosave and a cloud save function.

The naive loop looked reasonable:

  1. Mark the editor dirty after a change.
  2. Wait a few seconds.
  3. Export project JSON.
  4. Send it to the server.
  5. Mark the editor saved.

The problem is that users keep editing while saves are in flight.

If an old save finishes after newer edits were made, the UI must not say everything is saved.

That is how users lose trust.

The safer model is a dirty generation counter.

Every edit increments a number:

let dirtyGeneration = 0;
let savedGeneration = 0;
let saveInFlight = false;

function markDirty() {
  dirtyGeneration += 1;
  scheduleAutosave();
}
Enter fullscreen mode Exit fullscreen mode

When a save starts, the editor captures the current generation:

async function saveToCloud() {
  if (saveInFlight) return;
  if (dirtyGeneration === savedGeneration) return;

  saveInFlight = true;
  const generationAtStart = dirtyGeneration;
  const snapshot = exportProjectJson();

  try {
    const result = await putMapSnapshot(snapshot);

    if (dirtyGeneration === generationAtStart) {
      savedGeneration = generationAtStart;
      updateRevision(result.revision, result.updated_at);
      markCloudSaved();
    } else {
      scheduleAutosaveSoon();
    }
  } catch (error) {
    keepLocalDraft();
    scheduleRetry();
  } finally {
    saveInFlight = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

This protects against a common editor bug:

Save request A starts. The user makes edit B. Save request A finishes. The UI incorrectly says B is saved.

With generation tracking, the editor only clears the dirty state if no newer edits happened during the request.

6. Local drafts are a safety net, not the source of truth

Local autosave still matters.

But local storage should not become the final persistence model for an authenticated cloud editor.

The better model is:

  • local draft protects the user from crashes, reloads, and network failures
  • cloud save becomes the source of truth after a successful server write
  • server revision prevents stale overwrites
  • conflict handling decides what happens when the client is behind

That gives the user two layers of protection:

  1. Immediate local safety while editing.
  2. Durable cloud persistence once the network catches up.

For a creative tool, this matters more than it sounds. Losing a map is not a small bug. It is a trust-breaking event.

7. What changed in the product loop

Before this pass, the product had editor functionality, but the loop around it was fragile.

After the pass, the workflow became much tighter:

  1. The dashboard opens as the user's map workspace.
  2. New map always has a server-backed path.
  3. The server creates a real map record.
  4. The user lands in the Rust/WASM editor.
  5. The editor boots with map ID, API URLs, title, revision, permissions, and CSRF token.
  6. Changes save locally first.
  7. Cloud autosave persists the project snapshot with optimistic concurrency.
  8. Newer edits made during an in-flight save are not accidentally marked as saved.

That is the product loop a browser RPG map editor needs before deeper features matter.

What I would not overbuild yet

It is tempting to jump into advanced editor features immediately:

  • multiplayer editing
  • complex asset marketplaces
  • AI-generated maps
  • procedural dungeons
  • advanced sharing permissions
  • plugin systems

But those are not the first bottleneck.

The first bottleneck is trust.

For an RPG map editor, users need to believe that:

  • creating a map works
  • opening a map works
  • saving works
  • reopening works
  • their work will not disappear

Until that is true, advanced features are just decoration on top of a weak product loop.

Practical lessons

The useful lessons from this rebuild were simple:

  • Make critical actions work without JavaScript.
  • Pass explicit boot state into the editor.
  • Keep the editor API small.
  • Save title and project snapshot together.
  • Use server revisions for optimistic concurrency.
  • Track dirty generations on the client.
  • Treat local drafts as a fallback, not the final persistence layer.
  • Make saves boring.

The best editor infrastructure disappears when it works.

A user should not think about revisions, snapshots, CSRF tokens, local drafts, or save generations. They should create a dungeon map, close the tab, reopen it later, and see their work still there.

That is the real feature.

FAQ

Why use Rust and WebAssembly for a browser map editor?

Rust and WebAssembly make sense when the editor has engine-like requirements: canvas rendering, map state management, undo/redo, geometry, asset placement, and performance-sensitive operations. React can own the UI shell, while Rust/WASM owns the editing core.

Why not make the whole editor a React app?

React is good for panels, buttons, modals, inspectors, and dashboard UI. It is not the best place to put the entire editing engine. For a map editor, the canvas state, rendering model, command history, and project serialization benefit from a stricter engine layer.

Why use progressive enhancement for the New Map button?

Because creating a map is a critical action. If JavaScript fails, users should still be able to create a map and enter the editor. The enhanced JavaScript path can improve speed, but the server form path should remain reliable.

Why does autosave need a dirty generation counter?

Because users can continue editing while a save request is still running. A generation counter prevents an older save response from incorrectly marking newer edits as saved.

What is the most important part of cloud autosave?

The most important part is not the timer. It is correctness. The editor must know which edits were included in a save, which edits happened later, and whether the server accepted the write.

Final thought

Replacing the editor was the visible task.

Fixing the product loop was the real task.

For a browser-based D&D map maker, the canvas is only one part of the experience. The user also needs reliable creation, clear routing, safe autosave, and boring persistence.

That is what makes the editor feel like a product instead of a demo.

And the Result is here:

RPG Map Editor v2

RPG Map Editor v2

Top comments (0)