Skip to content

Web level editor

This browser editor for picogame scenes and levels lets you import sprite and tileset PNGs, paint tilemaps (with per-tile solid/coin/goal/hazard flags), place sprites, HUD labels, zones, spawn points and particles, set up a follow-camera, and save the whole game as one game.json the device runs directly. It also imports maps made in Tiled.

The canvas is a window onto a world that can be larger than the device screen, so you can use the editor for both fixed-screen and scrolling levels.

The button above opens the editor on its own page. Your session autosaves to this browser’s local storage, so coming back later (or a stray reload) resumes where you left off — Save writes game.json (into a chosen folder, or as a download) — under the name of the file you opened, or the one Save as… picks. On first run a short getting-started card appears; press ? any time for the full shortcut list.

Source: tools/editor/ — a static app, no backend. Clone it and python3 -m http.server -d tools/editor, or host it yourself (GitHub Pages works); node test.js runs its headless tests.

Prefer building maps in Tiled? Open takes a Tiled map directly: pick the .tmx/.tmj file together with its tileset files (.tsx/.tsj) and PNG images in one multi-file selection (the browser can’t follow file paths). What converts:

  • tile layers → tilemap layers; flipped/rotated tiles become native tile orientations
  • tile objects → sprites (rotation → angle, custom properties → data); rectangles → zones, points → points (custom properties come along)
  • bool tile properties (solid, coin, goal, hazard, …) → tile flags
  • tilesets are compacted to just the tiles the map uses (engine cells are one byte → up to 253 distinct tiles per tileset)

Unsupported Tiled features (animated tiles, sub-tile collision shapes, image layers, opacity/tint/parallax, polygon objects, infinite/isometric maps) are reported, never silently dropped. The same conversion exists as a CLI tool — tools/tiled2scene.py — for the offline bake pipeline; see the scene format.

▶ Try in playground runs the level in the browser with its real art: the editor bakes each imported PNG to the engine’s PAL8 format on the spot (same rules as the device bake — up to 255 colours, alpha < 128 = transparent), so what you see is what the handheld renders.

The Demos menu in the top bar loads ready-made levels. Open one to see how its World size and Camera are configured:

  • Sample (one screen) — a compact one-screen level showing every feature.
  • Scrolling platformer (bounded) — a 960×240 world (~3 screens wide). Follow-camera on axis x with bounds = the finite world, so the camera clamps at the left/right ends (a level with definite ends).
  • Open world (bigger both ways) — a 640×480 quest map, bigger than the screen in both dimensions. Follow-camera on axis xy, so you free-roam in x and y.

The getting-started card links to the same two scrolling demos.

Two sizes matter, and the editor shows them as a pair in the Level panel (Select tool, nothing selected):

  • World size — how big the whole level is, in pixels. This is where you set the size of your level: it defines the world extent and drives the camera bounds, Fit, the minimap, and the size of new tilemap layers. A live readout shows “≈ N × M screens”.
  • Device screen — one handheld view (320×240), drawn as the dashed white box on the canvas.

To make a level that scrolls, set a World size bigger than one screen.

  1. In the Level panel (Select tool, nothing selected), set World size bigger than one screen: type world w×h and Apply world size, or use a preset: 1 screen, 3× wide (platformer), 2×2 screens (open world). The editor offers to grow/crop your tilemap layers to fill it.
  2. Pick the Paint tool and a tileset (import a PNG, or + Colour tileset for placeholders); a new layer already fills the whole world. Paint your ground across it. Pan with Space-drag / middle-drag / the Pan tool, use the minimap (top-right) to jump around, Fit to frame the whole world, 100% for 1:1.
  3. Place a sprite — import a PNG, or click + Colour sprite for a placeholder block (no art needed; the demos’ hero is one) — and name one player (Select tool → name field).
  4. In Select set Camera → follow → player. Bounds default to the whole World size (auto); uncheck it to drag an explicit orange camera-bounds frame.
  5. Save writes game.json and the .pal8 art the board reads — the table below. For a pre-baked single level, Build ▾ → Baked module gives a <name>_scene.py ready for picogame_scene.load(pg, level1_scene.SCENE), with every imported PNG baked to PAL8 in the browser (no Python step).

Changing World size (or a layer’s own size in the Paint panel) pads with empty tiles on grow, and crops with a confirm if painted tiles would be lost. Everything is undoable (Ctrl+Z). The per-layer Layer size control is for cases where a layer should be smaller than the world or offset for parallax. For most levels, change World size.

Tool Key Does
Select 1 / V Pick + move objects (drag). Click a stacked spot again to cycle. Edit tile flags/colour.
Paint 2 / B Paint the active layer. Drag = brush, Shift-drag = rectangle, Alt-click = flood fill.
Place 3 / P Drop the chosen sprite.
HUD 4 Camera-fixed text label; also manages particle layers.
Zone 5 Drag a trigger rectangle, then tag it.
Point 6 Drop a named point (e.g. spawn).
Pan H / Space Pan the view (middle-drag also pans).

Wheel scrolls, Ctrl/⌘+wheel zooms to the cursor, +/ zoom, F fits. Arrows nudge the selection (Shift = ×10) or pan when nothing is selected. Ctrl+Z/Y undo/redo, Ctrl+C/V/D copy/paste/duplicate, Delete deletes, Esc deselects. The panel on the right changes with the active tool, so you only see the controls that tool needs. Routine feedback arrives as toasts that fade; a browser dialog appears only where a click would otherwise destroy something (New, Open over unsaved work, removing a painted layer) or where a name is needed (Save as…).

Every art chip has a button, and the Paint and Place panels offer ✎ Paint a sprite / ✎ Paint tiles for a blank one (16×16, eight tiles for a set). The pixel editor gives you a pencil, flood fill, an eraser, a colour picker and undo; the palette starts from the colours the art already uses, and any other colour is one click away. Multi-frame art is edited a frame at a time.

There is no anti-aliasing and no partial transparency anywhere in it, because there is none on the device: a pixel is a colour or it is see-through (the checkerboard). What you paint is written back as the asset’s own PNG, so Save bakes it to .pal8 exactly like imported art — including straight onto a board.

A flag gives a tile a meaning the game reads; it does not change how it looks. The loader builds fast lookup tables so the game asks the meaning instead of hardcoding tile numbers:

flag the game reads it as typical use
solid view.is_solid(tx, ty) walls / floor that block movement
coin view.tile_has(tx, ty, "coin") collectible the game removes on pickup
goal view.tile_has(tx, ty, "goal") level exit / win tile
hazard view.tile_has(tx, ty, "hazard") lava / spikes that hurt the player

Every asset chip has a button that downloads it as a PNG strip (frames left to right, tile 0 = empty) — the same format the editor imports. For a colour tileset or a placeholder sprite the strip is generated on the fly, so you can take a demo level’s “spritesheet”, paint real art over it in an image editor keeping the frame order, and re-import it: every tile the level uses keeps working.

Toggle Show flag badges on map (Paint panel) to see coloured corner badges on flagged tiles. Colour tilesets (Paint → + Colour tileset) make a placeholder set with no art; edit each colour with a picker, + add colour for more.

Everything the editor reads or writes is one of these. The names in bold are the buttons.

file what is inside who edits it
game.jsonSave the whole game: every level (maps as ASCII rows), the assets table (names, tile sizes, flags, legends — never pixels), sounds, zone stories, effects you in the editor, a coding agent in a text editor, scene_build.py fmt. The one file that is the game
hero.png, tiles.png your art, referenced by name from game.json you, in an image editor. The editor copies an imported PNG into the folder
hero.pal8, tiles.pal8Save (into a folder) or Build → Art the same art baked for the device: a small header, the palette, one byte per pixel nobody. Derived from the PNG, rewritten whenever it changes
story.py def name(d) story scripts a zone names with {"script": "name"} you or your agent, in Python. The editor only reads it
code.py the game: the runner the playground shows on Try in playground is a good start you or your agent
<name>_scene.pyBuild → Baked module one level baked as a Python module (art inside) nobody. For a device with no game.json loader; scene_build.py build --mpy is the full ship path

Open takes game.json (pick its PNGs, .pal8 files and story.py along with it, or keep a folder chosen so they load by themselves), the older scene.json / project.json / .pgproj.json files, and a Tiled map. Old files come back as a game.json project the next time you Save; nothing is lost. A level opened from a device pack that has only .pal8 art shows the recovered pixels (565 colours, hard alpha) and works exactly like one with PNGs.

The session still autosaves to the browser, so a stray reload resumes where you were — but the file on disk is the truth: with a folder chosen, an edit that appears there (an agent’s, say) is reloaded within two seconds while you have nothing unsaved, and announced when you do.

Save ▾ → Save to board… does the copying for you: pick the mounted CIRCUITPY drive once and the editor writes game.json, a .pal8 for every PNG asset, story.py and — unless you already have one it would overwrite — a code.py runner picked for the kind of level you built. It checks for boot_out.txt first (that is what makes a folder a CircuitPython board) and says if lib/ carries no picogame_*. CircuitPython reloads on its own, so the game is running before you have put the mouse down. (Chrome or Edge; other browsers still have the download path below.)

By hand it is the same files. The device reads game.json itself. Copy the folder to CIRCUITPY — game.json, the .pal8 files, story.py, code.py — and the game boots from the text file: every level is baked at start (40–60 ms each on an RP2040) and you can edit the JSON on the drive and press reset.

Terminal window
python3 tools/scene_build.py check # before copying: ids, legends, zones, effects, story refs
python3 tools/scene_build.py art # PNG -> .pal8 (Save does this too when a folder is chosen)
python3 tools/scene_build.py build --mpy # ship: build/ with game_bank.mpy + level_*.mpy + code.py

The build swaps one string in code.pyGame(pg, "game.json") becomes Game(pg, "game_bank") — and the same game runs from compact modules with only the current level in RAM. Both bakers (the browser and the tool) write byte-identical .pal8 files, so it does not matter which one ran last. See scene format for the loader API.

Open takes any game.json, not just one this editor saved: one you hand-edited, one a coding agent changed, one from a device. Layers, tile flags, zones, spawn points, effects and the camera all come back editable; the legend an agent chose for the map rows is kept as it is, and unknown keys ride through untouched.

If the two of you edit at the same time:

  • With a folder chosen, the editor notices a change to game.json on disk within two seconds. Nothing unsaved here → it reloads and says so. Something unsaved → it tells you once, and Save then asks before overwriting.
  • In git, one map row is one line of the file, so an agent adding a ceiling and you redrawing the floor merge cleanly. Only edits to the same row collide, and then the editor points at the conflict markers instead of failing to parse.

Save into your project folder (instead of downloading)

Section titled “Save into your project folder (instead of downloading)”

📁 Folder… in the header asks for a folder once (Chrome/Edge), and from then on Save writes straight into it: game.json, a .pal8 next to each PNG asset, a source PNG the folder lacks, and story.py if the Story panel still holds legacy script bodies and no story.py exists. Without a folder everything downloads: game.json from Save, the .pal8 files from Build → Art.

This is what makes a level something you and a coding agent can both work on: the editor writes the same file the agent reads, in the repo where the game lives.

ASCII maps — a level a human can read in a diff

Section titled “ASCII maps — a level a human can read in a diff”

Tilemaps are saved as one string per row over a legend that lives with the tileset:

"assets": { "tiles": { "type": "tileset", "src": "tiles.png", "tile": [16, 16],
"legend": { ".": 0, "#": 1, "o": 2 } } },
...
{ "kind": "tilemap", "asset": "tiles", "rows": ["....o....", "..####...", "#########"] }

The legend only grows: a tile painted for the first time gets the next free character, and a character already in use keeps its meaning through every Save — so a map’s diff stays a picture and the letters you or an agent picked survive. A tile with a per-cell flip/rotation gets its own character; a layer with more distinct tiles than the alphabet holds (91) keeps a number grid.

Two mistakes a hand edit can make, and where they are caught: a character that is not in the legend bakes as an empty tile, and rows of different lengths make the map non-rectangular — scene_build.py check reports both before anything reaches the device.

One file, no generated code, and both of you use the same tools:

  1. You: pick 📁 Folder… once so the editor writes into the repo, build the level, Save.
  2. The agent: edits game.json — map rows, entity positions, zones and their story data, effects — and story.py / code.py. It runs scene_build.py check and fmt before handing back, and art after touching a PNG. It never edits .pal8 files or anything in build/.
  3. You: the change shows up in the editor by itself (folder chosen) or in git diff as a picture. Keep editing, Save, repeat.
  4. Ship with scene_build.py build --mpy when the game is done; while iterating, the device runs from game.json directly.

Sharing a game with the agent through a chat instead of a folder is the same file: attach game.json (and story.py), the art travels once.