DEV Community

Cover image for I open-sourced my browser-based pixel art editor (with auto-tiling tilesets)
Comficker
Comficker

Posted on

I open-sourced my browser-based pixel art editor (with auto-tiling tilesets)

After two years of building Simple Pixel Art as a side project, I finally open-sourced the frontend under MIT:

GitHub: https://github.com/comficker/simplepixelart
Try it (no signup): https://simplepixelart.com/editor

It's a pixel art editor that runs entirely in the browser β€” sprites, frame-by-frame animation, isometric mode, and the feature I'm most proud of: a tileset builder that auto-generates Wang-16 / blob-47 terrain sets from a single base tile, exporting ready-to-use Godot 4 TileSet or Tiled .tsx files.

The editor

What's inside

  • πŸ–ŒοΈ Editor β€” layers, selections, mirror drawing, unlimited undo, and an infinite-canvas workspace where multiple boards live on one desk
  • 🎞️ Animation β€” frame timeline, onion skin, per-frame duration, GIF + spritesheet export
  • 🧱 Tilesets β€” group tiles, auto-generate terrain sets, test them in a tilemap painter, export to Godot/Tiled
  • πŸ’Ž Isometric mode β€” 2:1 dimetric grid with an iso-line tool
  • πŸ–ΌοΈ Converter β€” image β†’ pixel art with median-cut quantization and an orphan-pixel cleaner
  • ⌨️ Keyboard-first β€” B brush Β· E eraser Β· G fill Β· V move Β· M select Β· 1–5 brush size

The stack is Nuxt 4 + Pinia, but the interesting parts have nothing to do with the framework. Here are three design decisions I'd make again β€” and one bug I'm still embarrassed about.

1. The renderer is just a 2D canvas and a plain object

No WebGL, no engine, no virtual scene graph. The whole artwork is:

// One layer = one flat map of "x_y" keys β†’ palette indices
layer.pixels = { "0_0": 3, "1_0": 3, "5_7": 12, ... }
Enter fullscreen mode Exit fullscreen mode

Early versions rebuilt the full pixel map every frame β€” at 128Γ—128 that's ~10–18ms per brush stroke, which feels like drawing through mud. The fix was an incremental dirty-pixel buffer: every mutation goes through one function that patches only the changed pixels into an offscreen canvas.

The invariant that keeps it correct: every pixel write either goes through setPixelByIndex (dirty-tracked) or explicitly calls markFullRedraw(). One code path, one rule. A brush stroke now costs ~0.006ms/frame.

2. Guest-first storage: localStorage until it hurts, then IndexedDB

I wanted the editor fully usable without an account β€” accounts only add cloud sync and gallery publishing. So persistence is local by default:

  • Single artworks + undo history β†’ localStorage
  • The multi-board workspace snapshot (full pixel data) β†’ IndexedDB, because localStorage's ~5MB quota dies fast when one workspace holds a dozen boards
  • A tiny workspace_layout key stays in localStorage as a fallback, so even if the big snapshot write fails on quota, board positions and settings survive a reload

Sign in later and everything migrates to the cloud β€” failed uploads stay local instead of being wiped (learned that one the hard way in an audit).

3. The bug: 12.4 seconds to fit 16 isometric boards

My favorite recent bug. Pressing "FIT" (zoom out to see the whole workspace) with 16 isometric boards froze the tab for 12.4 seconds.

The isometric lattice was drawn like this β€” one diamond at a time:

for (let j = 0; j < rows; j++) {
  for (let i = 0; i < cols; i++) {
    path.moveTo(top);   path.lineTo(right)
    path.lineTo(bottom); path.lineTo(left)
    path.closePath()
  }
}
Enter fullscreen mode Exit fullscreen mode

That's O(cols Γ— rows) quads β€” a 128Γ—128 board with 1Γ—1 cells is 32,000 diamonds, and every shared edge gets stroked twice. Sixteen boards β‰ˆ a million line segments in one frame.

But an iso lattice isn't thousands of diamonds. It's two families of parallel lines with slopes Β±cellH/cellW:

// Family "+": y = sΒ·x + c, stepping c by cellHΒ·zoom
for (let c = first; c <= cMax; c += step) {
  path.moveTo(0, y0(0, c))
  path.lineTo(artPxW, y0(artPxW, c))
}
// Family "βˆ’": same, slope negated
Enter fullscreen mode Exit fullscreen mode

That's O(rows + cols) β€” ~500 long lines instead of 32k quads, each edge drawn exactly once. 12.4s β†’ 16ms. Same pixels on screen.

The lesson generalizes: when a canvas draw is slow, don't reach for caching first β€” check whether you're drawing the same geometry more than once.

On open-sourcing a solo project

Honest notes from the process, in case you're considering it:

  • I squashed the entire history into one clean commit before flipping public. Two years of "fix", "wip", "asdf" commits are nobody's business.
  • The repo is the frontend; the community API stays hosted. I documented that honestly in the README rather than pretending the whole thing is self-hostable.
  • The surprising amount of work wasn't code β€” it was README, LICENSE, CONTRIBUTING, issue templates, secrets audit, and a "fresh clone actually builds" test.

Feedback welcome

If you make game assets: what's missing before this could replace your current tool for quick sprites? Issues and PRs are open β€” there's a CONTRIBUTING.md and the code style is deliberately boring.

Repo: https://github.com/comficker/simplepixelart Β· Editor: https://simplepixelart.com/editor

Top comments (0)