Building an In-Browser Level Editor: Save Formats, Undo Stacks, and Shipping Player-Made Content

How many of the players who finish your game on launch weekend will still be opening it ninety days later? For most small browser studios the honest answer sits in the single digits, and hand-building more levels is the most expensive possible fix.
A level editor changes that arithmetic. Every hour a player spends building is an hour of content you did not have to fund, and every level they publish becomes someone else's Tuesday night.
That said, editors are deceptively expensive to ship well. Snapping a tile to a grid and dragging a prop around is the easy part, and the three systems underneath the canvas are where these projects usually stall.
This guide covers those three in the order they tend to break: a save format you can version without orphaning old levels, an undo stack built on commands rather than snapshots, and a moderation path that keeps broken or hostile submissions away from other players.
Why Player-Made Levels Are The Cheapest Retention A Small Studio Has
Authored content has a hard ceiling. A designer produces a polished level in somewhere between four and forty hours depending on the genre, and a motivated player consumes it in six minutes.
User-generated content inverts that ratio. Once the editor is live, your content supply scales with your player count instead of your headcount, and the players who build are almost always the ones who stay.
What's more, builders generate a second retention loop at no additional cost. Someone who publishes a level comes back to check play counts, ratings, and comments, which is a return visit you never had to buy.
A level editor turns retention into a supply problem you no longer fund. Builders return to check plays and ratings on their own levels, and every published level is fresh content for someone else.
The Three Systems That Break First
Editors fail in a predictable order, and none of the failures are about rendering. Here are the three that account for most of the pain, in roughly the order teams hit them:
- The save format. It ships without a version field, then the entity model changes in week six and every level built before that stops loading.
- The undo stack. It gets built on full-state snapshots, which works fine on a test level and turns into hundreds of megabytes on a real one.
- Submission moderation. Nobody plans for it, and the first unplayable level or hostile level name lands in front of real players within days of the browse page going live.
All three are cheap to prevent and expensive to retrofit. The rest of this guide takes them one at a time.
Designing A Save Format You Can Version
The save format is the contract between the editor you have today and the editor you will have in a year. Treat it as a public API, because once players have built levels with it, that is exactly what it is.
Put The Version Number In Before You Need It
Every level file should carry an integer schema version as its first field, starting at 1 on the very first commit. The cost is one line now and a full content wipe later.
Migrations should be a chain of small pure functions — migrate1to2, migrate2to3 — applied in order until the file reaches the current version. This keeps each step small enough to reason about and lets you test the whole chain against a fixture directory of real historical levels.
Keep that fixture directory in the repo and run it in CI. A handful of representative levels from each shipped version turns "did the migration break anything" from a question into a test result.
Be aware that once a version has shipped, its meaning is frozen. If a field needs to change type or semantics, add a new field and migrate forward rather than redefining the old one, because somewhere a player still has a browser tab holding the old shape.
Reference Entities By ID, Never By Index
The most common structural mistake is storing references as array positions. The moment you delete an entity and compact the array, every stored reference silently points at the wrong object.
Instead, store a monotonically increasing id counter in the file itself and give every entity a stable identifier. Deleting becomes safe, undo becomes trivial because a re-created entity keeps its original id, and migrations stop depending on array ordering.
Reference entities by stable IDs, never by array index. Store a nextId counter in the file itself, so deleting, undoing, and migrating never silently repoint a trigger at the wrong object.
JSON Or A Binary Format?
Start with JSON and stay there longer than you think you should. It inspects in DevTools, diffs in git, gzips well over the wire, and costs nothing to hand-edit when a player reports a corrupt level.
The figures below are approximate, measured against a level of roughly one thousand tiles plus a few dozen entities, and they move considerably with your specific data shape. Treat them as an ordering rather than a benchmark:
| Format | Approximate size, 1,000-tile level | Debuggability | Best for |
|---|---|---|---|
| Plain JSON | ~40–90 KB before gzip | Readable in DevTools, diffs in git | Everything, until size genuinely hurts |
| JSON with run-length encoded tile arrays | ~6–15 KB | Mostly readable; tile array is opaque | Grid games with large uniform regions |
| MessagePack or CBOR | ~25–60 KB | Needs a decoder to inspect | Dense object data, minimal code change |
| Custom ArrayBuffer | ~4–20 KB | Hex editor territory | Very large levels, replay streams, hard bandwidth caps |
Note that gzip or Brotli at the transport layer erases much of the gap. Compressing a JSON level before you store it, or letting your CDN compress it in transit, usually buys more than switching serializers does.
Overall, the format decision matters far less than the versioning discipline around it. A versioned JSON blob you can migrate beats an elegant binary format you cannot read six months later.
Building An Undo Stack That Survives A Three-Hour Session
Undo is the feature players never mention when it works and abandon your editor over when it fails. It also carries more edge cases than the rest of the editor combined.
Commands, Not Snapshots
The snapshot approach — deep-clone the level after every edit, push the clone on a stack — is tempting because it takes twenty minutes to write. It also allocates the entire level on every brush stroke, and a large level under a fast paint tool will produce hundreds of megabytes of garbage within a few minutes of work.
The command pattern stores the delta instead. Each edit becomes an object with an execute method, an undo method, and only the data both need — the tile coordinates, the previous value, the new value.
A tile paint command holding two integers and two tile ids costs a few dozen bytes rather than a few megabytes. That difference is what lets an undo history survive a long session on a large map.
Use the command pattern rather than full-state snapshots. Each edit stores only its own delta and inverse, which keeps a long editing session in kilobytes instead of the hundreds of megabytes snapshots allocate.
Coalescing, Transactions, And The Redo Branch
A single drag generates one command per frame, which means a two-second drag produces roughly 120 undo entries. Players expect one.
Coalescing solves this: give each command a merge method, and when a new command arrives inside a short window targeting the same object and property, fold it into the previous entry rather than pushing a new one. Ending the merge window on pointer-up, tool change, or selection change matches player intuition closely enough.
Composite commands handle the opposite direction. A paste, a multi-select delete, or a room-generation macro should push one transaction containing many child commands, executed forward in order and undone in reverse.
Then there is the redo branch. When a player undoes three steps and then makes a new edit, the standard behavior is to discard the redo stack entirely, and effectively every editor your players have already used behaves this way.
Keep in mind that selection and camera state are not edits. Undoing should not move the viewport or reshuffle the selection in ways the player did not ask for, so store selection alongside a command for restoration but never create commands for selection changes alone.
Cap The Stack By Memory, Not By Step Count
A fixed limit of 100 steps behaves completely differently depending on what the player is doing. One hundred tile paints is trivial, while one hundred region fills on a large map is not.
Have each command report an approximate byte cost and trim the oldest entries once the total crosses a budget — somewhere around 32 MB is a reasonable starting point for a tab that also holds your renderer and your assets.
Cap the undo stack by memory rather than step count. Have each command report an approximate byte cost and drop the oldest entries past a budget near 32 MB, so heavy and light edits both behave.
All of this adds up to an undo system that is boring in the best possible way. Commands keep memory flat, coalescing keeps the history legible, and a memory cap keeps the tab alive through a long build.
Autosave And Crash Recovery
Browser tabs die. They die to an out-of-memory kill on a low-end Chromebook, a stray reload, an OS update, and a player closing the wrong tab at one in the morning.
Autosave to IndexedDB on a timer of roughly 20 to 30 seconds, and again on visibilitychange and beforeunload. Keep a small ring of the last three autosaves rather than a single slot, because a corrupt write to your only recovery record is worse than having no recovery record at all.
For the storage layer itself, the tradeoffs between IndexedDB, the Origin Private File System, and localStorage are covered in more depth in our guide to IndexedDB storage patterns for browser games. The short version is that localStorage is synchronous and capped near 5 MB, which rules it out for anything holding level data.
Autosave to IndexedDB every 20 to 30 seconds plus on tab blur and unload, and keep the last three autosaves in a ring. A crash then costs a player half a minute of work instead of an evening.
Once a level leaves the local tab, the same versioned blob should travel to your backend unchanged. If your game already syncs progress, reuse that path — the design considerations overlap almost entirely with cross-platform save state.
Validating Levels Before Anyone Else Plays Them
A published level is untrusted input arriving from a client you do not control. Treat it exactly as you would any other client payload, which means validating server-side and never trusting the editor's own checks.
The validation pass should run automatically on submission and reject or flag the level before a human ever sees it. The checks that earn their keep include:
- Hard budget caps. Enforce a maximum entity count, file size, dimension, and custom-asset payload, and enforce them on the server rather than in the editor UI.
- Schema and bounds validation. Every entity should parse against the current schema with coordinates inside the declared level bounds and property values inside declared ranges.
- Reference integrity. Triggers pointing at deleted targets, doors keyed to nonexistent keys, and spawn points outside the playable area should fail the pass rather than crash at runtime.
- A headless solvability check. Run the level in your simulation with no renderer and confirm a spawn exists, an exit exists, and, where your genre allows it, that a naive pathfinder can reach that exit.
- Performance sampling. Simulate a few hundred ticks headless and reject levels whose entity counts or collision pair counts blow past what your target device tier can hold.
The headless pass is the highest-value item on that list and the one most often skipped. If your simulation is already separated from your renderer — which is the main practical argument for an ECS architecture in browser games — running it server-side is mostly a packaging exercise.
Determinism makes this considerably stronger. If your simulation is deterministic, a builder can submit an input replay alongside the level and your server can verify completability by replaying it, which is the same machinery described in our guide to deterministic lockstep simulation.
Validate on the server, always. A player who can post a level payload directly to your API eventually will, and the reasoning behind server-authoritative anti-exploit patterns applies to every field in a submitted level.
Moderating Submissions Without A Moderation Team
Automated validation catches broken levels. It does not catch a level named after a slur, a level built to spell something in tiles, or a level whose only purpose is to waste ninety seconds of another player's time.
A three-tier model works well for a small studio and costs very little to run. Each tier only sees what the tier below it could not resolve:
- Tier one, automated gates. Schema validation, budget caps, and the solvability pass run on every submission, plus a normalization and blocklist pass on free-text fields to catch the obvious cases.
- Tier two, a trusted-builder track. Accounts with a history of accepted levels publish immediately, while first-time submitters land in a queue you can clear in batches.
- Tier three, player reports. A report button offering a short list of concrete reasons routes into a queue, and a report threshold auto-unlists a level pending review rather than waiting for you to wake up.
Free text is the real risk surface, not geometry. Level names, descriptions, and author display names are where nearly all genuinely harmful content arrives, so consider shipping a curated name-part picker before you ship an open text field at all.
Two mechanical details save a surprising amount of queue time. Hash the canonical level payload on submission so duplicate re-uploads collapse into a single review, and store a moderation state field — pending, published, unlisted, rejected — instead of deleting rows, so an appeal has something to appeal to.
Moderate in three tiers. Automated validation rejects broken levels, a trusted-builder track skips the queue for proven accounts, and player reports auto-unlist a level past a threshold pending review.
Be aware that unlisting beats deleting in nearly every case. A reversible state change survives a false positive, and a false positive on someone's twelve-hour build is how you lose the exact player you most wanted to keep.
Shipping Player Content To Other Players
A level nobody can find is worth roughly as much as a level nobody built. The distribution layer deserves the same attention as the editor itself.
Serve level payloads from a CDN with immutable, content-hashed URLs so a published level caches indefinitely and an edit simply produces a new URL. Generate a thumbnail at publish time by rendering the level once headless, because a browse page of text rows converts far worse than a browse page of images.
Custom assets are where payload sizes get away from you. If players can upload sprites or audio, cap and transcode them at ingest, then load them through the same progressive path described in our notes on asset streaming for browser games.
Discovery ranking can stay simple for a long time. Completion rate, plays in the last seven days, and a rating with a confidence adjustment will outperform a raw star average, which mostly rewards whatever got posted to the community Discord first.
Finally, decide early whether the editor and the game share a renderer. Sharing one eliminates an entire class of "it looked different in the editor" bugs, and the practical constraints are laid out in our comparison of Canvas versus WebGL rendering for browser games.
What To Build First
Sequencing matters more than any individual decision above. This order gets a working editor in front of players quickly without creating debt you cannot pay down:
- A versioned save format with stable entity IDs, a migration harness, and fixture tests, in place before any editor UI exists.
- Command-pattern undo with coalescing, wired in before the second tool ships rather than retrofitted after the fifth.
- Local-only editing with IndexedDB autosave and share-by-file, which lets you learn what players actually build before you own a publishing pipeline.
- Server-side validation and the headless solvability pass, live before the first submission endpoint accepts anything.
- Publishing, browse, and the report queue, shipped together, because a browse page without a report button is a support burden from day one.
Steps one and two are the ones that cannot be added later without a rewrite. Everything after them is incremental.
Common Questions About In-Browser Level Editors
A few questions come up on nearly every editor project, usually somewhere between the first prototype and the first public submission:
Should the editor and the game share the same renderer?
Share it whenever you can. A separate editor renderer drifts from the game renderer within weeks, and every drift becomes a bug report that a level looked different while building it than while playing it.
Can players share levels without a backend?
Yes, and it is a good first milestone. Export the versioned payload as a downloadable file or a compressed URL fragment, which gets you real player levels and real format feedback before you own moderation.
What stops a player from hand-editing a level file?
Nothing, and that is fine as long as the server validates on import. Assume every field arrived from a text editor, then let schema validation, budget caps, and the solvability pass decide what publishes.
How large should a published level payload be?
Pick a cap you can defend on a mobile connection, often somewhere between 100 and 500 KB compressed. Enforce it server-side and surface the remaining budget in the editor so builders learn the limit before submitting.
Does the editor need a separate mobile layout?
It needs a separate input model more than a separate layout. Direct-manipulation tools, larger hit targets, and an explicit undo button matter far more on touch than any rearrangement of desktop panels.
Where To Go From Here
An editor is a long-lived system, and the parts that decide whether it survives contact with players are settled in the first week of the project. A version field, a command stack, and a validation pass are cheap on day one and close to impossible to add cleanly on day two hundred.
If you are scoping one now, the rest of our browser game engineering guides cover the neighboring systems — storage, rendering, simulation, and networking — at the same level of detail.


