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.
Import a Tiled map
Section titled “Import a Tiled map”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.
Learn by loading a demo
Section titled “Learn by loading a demo”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.
World size vs. device screen
Section titled “World size vs. device screen”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.
Make a level that scrolls
Section titled “Make a level that scrolls”- In the Level panel (Select tool, nothing selected), set World size bigger than one screen:
type
world w×hand 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. - 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.
- 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). - In Select set Camera → follow → player. Bounds default to the whole World size (auto); uncheck it to drag an explicit orange camera-bounds frame.
- Save writes
game.jsonand the.pal8art the board reads — the table below. For a pre-baked single level, Build ▾ → Baked module gives a<name>_scene.pyready forpicogame_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.
Tools & controls
Section titled “Tools & controls”| 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…).
Paint art without leaving the editor
Section titled “Paint art without leaving the editor”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.
Tile flags
Section titled “Tile flags”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.
One game, one file
Section titled “One game, one file”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.json — Save |
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.pal8 — Save (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.py — Build → 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.
Run on a device or in the simulator
Section titled “Run on a device or in the simulator”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.
python3 tools/scene_build.py check # before copying: ids, legends, zones, effects, story refspython3 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.pyThe build swaps one string in code.py — Game(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 a level someone else edited
Section titled “Open a level someone else edited”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.jsonon 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.
Working with a coding agent
Section titled “Working with a coding agent”One file, no generated code, and both of you use the same tools:
- You: pick 📁 Folder… once so the editor writes into the repo, build the level, Save.
- The agent: edits
game.json— map rows, entity positions, zones and their story data, effects — andstory.py/code.py. It runsscene_build.py checkandfmtbefore handing back, andartafter touching a PNG. It never edits.pal8files or anything inbuild/. - You: the change shows up in the editor by itself (folder chosen) or in
git diffas a picture. Keep editing, Save, repeat. - Ship with
scene_build.py build --mpywhen the game is done; while iterating, the device runs fromgame.jsondirectly.
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.