HTML5 game publishing

Build a Small Level Editor Into Your Puzzle Game

A development-only in-game editor: one level format, painting, instant playtest, autosave, export, validation and undo.

PlayKovo Team4 min read
Games currently available on PlayKovo. Open a title to see its current listing.

Hand-placing levels in code or in a long JSON file works for the first ten levels. By level forty it becomes slow and error-prone: a typo breaks a level, you can't see the layout without running the game, and testing a change takes a full reload and many clicks. A small in-game level editor solves this. It does not need to be pretty. This post explains how to build a minimal editor for a grid or tile-based puzzle game that saves straight into your level format.

What the editor needs to do

Keep the scope small. For most puzzle games, an editor needs only:

  1. Show the level grid.
  2. Pick a tile or object type from a palette.
  3. Click or drag to place it, right-click to erase.
  4. Resize the grid.
  5. Play the level instantly, and return to editing.
  6. Export the level as data you can paste into your level file.

Everything else, such as undo, copy-paste or validation, is a bonus you add when you need it.

Build it into the game, behind a flag

The fastest editor reuses your game's own renderer and level loader. Open it with a URL parameter in development builds:

const params = new URLSearchParams(location.search);
const EDITOR = import.meta.env.DEV && params.has('editor');
if (EDITOR) startEditor(); else startGame();

The DEV check (from your bundler, such as Vite) makes sure the editor code is not active in release builds. If your bundler supports it, the editor code is removed entirely from production. Make sure your release build does not ship a hidden editor or debug panel that a reviewer or player could open.

A clear level format

The editor and the game must agree on one format. A simple, readable one is easiest to work with:

{
  "id": "forest-07",
  "w": 7, "h": 9,
  "tiles": [
    "#######",
    "#..B..#",
    "#.#.#.#",
    "#..P..#",
    "#######"
  ],
  "par": 12
}

Rows of characters are easy to read in a diff and easy to fix by hand. For games with many object properties, use a list of objects with type, position and options instead. Give each level a permanent string ID, so saved progress stays correct when you reorder levels.

The editing loop

let brush = '#';
canvas.addEventListener('pointerdown', e => paint(e));
canvas.addEventListener('pointermove', e => { if (e.buttons) paint(e); });
canvas.addEventListener('contextmenu', e => e.preventDefault());

function paint(e) {
  const { cx, cy } = screenToCell(e);
  if (!inBounds(cx, cy)) return;
  const value = e.buttons === 2 ? '.' : brush;
  if (level.get(cx, cy) === value) return;
  pushUndo();
  level.set(cx, cy, value);
  redraw();
}

addEventListener('keydown', e => {
  if (palette[e.key]) brush = palette[e.key];   // number keys pick tiles
  if (e.key === 'p') playtest();
  if (e.key === 'z' && (e.ctrlKey || e.metaKey)) undo();
});

Keyboard shortcuts for the palette save a lot of time. Show the current brush in a corner of the screen.

Instant playtesting

The most valuable feature is pressing one key to play the level you are editing, and another to return to the editor with the level unchanged. Clone the level data before playing, so the play session cannot modify the edited version:

function playtest() {
  const copy = structuredClone(level.data);
  startGame({ level: copy, onExit: () => startEditor(level.data) });
}

This turns the design loop from minutes into seconds, which is where most of the editor's value comes from.

Saving and exporting

Keep it simple:

  • Autosave the current level to localStorage on every change, so a reload does not lose work.
  • Export by copying the level JSON to the clipboard with navigator.clipboard.writeText, then paste it into your levels file.
  • If you run a local dev server, you can add an endpoint that writes the file directly. Only do this in development.

Validation

Add checks that run whenever a level changes, and show warnings in the editor:

  • Exactly one player start, at least one goal.
  • Counts that must match, for example the number of boxes and targets.
  • No objects outside the grid.
  • If you have a solver, run it with a time limit and show "solvable in N moves" or "no solution found".

A solver in the editor is the strongest tool of all for puzzle games: you see at once whether a change made the level impossible or trivially easy.

Undo

A simple undo stack of full level snapshots is enough for small grids:

const undoStack = [];
function pushUndo() {
  undoStack.push(structuredClone(level.data));
  if (undoStack.length > 100) undoStack.shift();
}
function undo() {
  const prev = undoStack.pop();
  if (prev) { level.data = prev; redraw(); }
}

To avoid one undo step per cell when dragging, push a snapshot only on pointerdown, not on every cell painted during the drag.

Level list and ordering

Add a simple screen that lists all levels with their IDs, lets you open one in the editor and lets you move levels up and down. Since progress is saved by ID, reordering does not break saves.

Don't let the editor become the project

Editors grow easily. Add a feature only when you notice yourself doing the same manual task several times. An ugly editor that saves you an hour a day is better than a beautiful one that took two weeks to build.

Checklist

  • Editor in the game, behind a development-only flag.
  • One readable level format with permanent IDs.
  • Paint, erase, palette shortcuts, resize.
  • One-key playtest and return.
  • Autosave and copy-to-clipboard export.
  • Validation, ideally with a solver.
  • Undo, one step per action.
  • Not shipped in the release build.

For generating and checking levels automatically, see our post on solver-checked procedural levels on the developer blog.

We use optional cookies for site analytics and site advertising. You can change your choice later. Privacy policy.