Magic Sprite
Self-initiated · 2026 · Design & Development
I designed and built it, and kept the controls grey so the artwork is the only saturated colour on screen.
A pixel art editor and animator that runs in the browser, which I designed and built for pixel artists and the game developers who use their sprites. The controls stay grey so the artwork is the only saturated colour on screen, and baking an animation keeps the original drawing.
About thirty thousand lines of TypeScript and CSS. It has bones with forward kinematics, a bake you can regenerate or undo, RotSprite rotation that adds no new colours, and sprite sheet export with the tags, origin and events a game engine reads.
Magic Sprite is a pixel art editor and animator that runs in the browser. It has to work for two people: the artist who draws and cleans up every frame, and the developer who loads the finished sheet into a game. A lot of what follows began as a note about something that got in the way while I was using it.
The work
- Source: 30k (lines of TypeScript and CSS)
- Scope: 96 (requirements checked against the code)
- Kinds of state: 4 (kept apart, drawing to export)
- Invented colours: 0 (after a RotSprite rotation)
- Easing curves: 1 (used at two durations)
- Sample rig: 7 (bones, built from source)
Who it is for
Two people depend on what the editor makes. What each of them needs comes from the animation scope and from using the editor myself.
The artist
Draws every frame, then cleans each one up by hand.
Needs
- Colours that read true, with nothing tinted around the canvas
- Timing that can still change after cleanup has started
- Rotations that keep one-pixel details
Gets in the way
- A bake that deletes the keys and freezes the timing
- A cross-fade that invents colours the palette never had
The game developer
Loads the sheet into an engine, without the project file beside it.
Needs
- Which frames make up each animation, which way it plays and for how long
- A point to stand the sprite on, and the frames where a footstep or a hit lands
Gets in the way
- Frames named 12 instead of walk_0
- An origin at (0, 0), the top-left corner
Where the animation pipeline broke
Five steps from a posed rig to a sheet a game can load, and what went wrong at each. Three of the failures were in early builds of Magic Sprite, and the missing tweening is Aseprite's.
- Pose. Turn a joint and key the frame. What went wrong: Keying the rotation also froze the position on that frame. What it does now: A key holds only the channels you set.
- Tween. Fill in the frames between two keys. What went wrong: Aseprite has no built-in tweening, and it comes from community extensions such as Tweencel. What it does now: Tweens run live, with easings, paths and bezier curves.
- Bake. Write the poses into pixels. What went wrong: Baking deleted every key, so the frames could not be regenerated. What it does now: The drawing and the keys are kept, and baked frames regenerate.
- Clean up. Fix each frame by hand. What went wrong: This is where wrong timing shows up. What it does now: Frames you painted are marked edited and survive a regenerate. Wrong timing sends the work back to the tween.
- Export. Hand the sheet to a game. What went wrong: The sheet listed frames and durations and nothing about which frames were the walk. What it does now: Tags, frame names, the origin and events go out with it.
Cleanup and retiming go back and forth until the motion looks right. Fixing a frame by hand must not take away the ability to retime it, and the state model further down exists to protect that.
What using it turned up
Problems I found by drawing and animating with the editor and measuring what it did. Each one is written up in the project README along with its fix.
| Where | What went wrong | What it does now |
|---|
| Opening the app | A blank 64×64 canvas. Nothing was stored, and closing the tab lost the work | Opens on a library of your sprites, saved 1.2 seconds after you stop drawing |
| New sprite | Eight square sizes could not make a 32×48 character or a 320×180 scene, and the dialog ran off a laptop screen | Twenty-four sizes in four groups, up to 2048 square, in a dialog 517px tall |
| Commands | Holding Ctrl opened the quick menu after 170 ms, which raced every real shortcut | Ctrl+K, the chord command palettes already use |
| Undo | A Ctrl+Z held a moment too long repeated and undid two steps | Held Ctrl chords do not repeat, while nudging and frame stepping still do |
| Shading | The Shade tool did nothing until you left the canvas and picked a ramp in another panel | With no ramp chosen, it offers to build one from the colour in hand |
| Auto shading | Eighteen sliders and no starting point. The first move was a guess | Six finished looks to start from and four sliders, with the rest behind More controls |
| Long animations | Sixteen frames pushed the timeline past the edge of the window, a bug that four frames had hidden | The frame strip scrolls inside the timeline |
The last one only showed up once the sixteen-frame sample character existed.
Conventions borrowed from other tools
People switching from another editor bring their habits with them. Where a convention already existed, I followed it.
| Convention | Borrowed from |
|---|
| Ctrl+K opens the command menu | Command palettes in other apps |
| The left mouse button walks the shading ramp to the left | Aseprite's documentation |
| Shift-click selects a run of colours and Ctrl-click toggles one | Aseprite's palette |
| Every frame laid out on the canvas at once | Photoshop's artboards |
| Cancel first, and the confirming button named for what it does | Apple's platform guidance |
Why the controls are grey
Every hue in the interface is one more colour the artist has to look past while judging their own. The controls use almost none. Emphasis comes from value: the primary button is filled graphite, a selected item sits one step down the grey stack, and nothing else is tinted.
The same rule covers everything around the canvas. The backdrop, the transparency checkerboard and every thumbnail are neutral grey, because a tinted surround makes a pixel look like a colour it is not.
Colour inside a button is a claim on the eye, and this interface sits beside artwork whose colour is the entire point.
From the Magic Sprite design notes
The grey stack
I measured every grey. None has more than eight points of spread between its red, green and blue channels, and most have under five. The last swatch is the old, lilac-leaning ink-dim that the current one replaced.
- paper #f3f3f4: spread 1
- card #ffffff: spread 0
- sunken #ececee: spread 2
- line-strong #cbcbcf: spread 4
- ink-faint #98989e: spread 6
- ink-dim #66666b: spread 5
- graphite #3a3a3e: spread 4, fills the primary button
- ink #2e2e31: spread 3, stops short of black
- ink-dim, retired #64607a: spread 26, a lilac doing a grey job
Reading at eleven pixels
These are the three text greys on the paper colour. Dim has to stay readable at 11px. Faint is allowed to fail, because it is only used for decoration.
- ink on paper: #2e2e31 on #f3f3f4
- ink-dim on paper: #66666b on #f3f3f4
- ink-faint on paper: #98989e on #f3f3f4
The ratios are calculated from the hex values when this page renders.
Nine hues from the logo
The rainbow is read left to right off the logo, in pastel so it can sit beside artwork all day. It only appears where there is no artwork: the hairline under the menu bar, one hue per dock tab and the splash screen. Where the logo itself is the subject, the same hues go deeper, and those values are listed beside each swatch.
- coral #ff9a76: mark #ef5f3c
- amber #ffc46b: mark #f2900f
- butter #ffe38a: mark #d9ab13
- mint #a8e6b5: mark #46b869
- teal #79dccd: mark #12a99a
- sky #77c8f2: mark #1c8fdd
- blue #7aa6f0: mark #3f6bd6
- lilac #a897ea: mark #7b55d2
- pink #ef9cc4: mark #d4569b
Motion and chrome
How the interface moves, and what it leaves out when there is nothing to act on.
- Easing. Everything uses one easing curve, which overshoots slightly and settles, at two durations: one for things arriving and an instant one for presses. A pressed button shrinks a little instead of moving down, which keeps its label under the cursor.
- The entrance plays once. The home screen animates in once per session. On the fortieth visit of an afternoon, the same animation would only be a wait.
- No menus on Home. Home has no menu bar and no status bar. Those menus act on a document, and there is no document open there.
What the editor does
The tools, grouped by what your hand is doing. Holding Alt with any tool magnifies the pixels under the cursor for picking an exact colour, and Ctrl+K runs any command without taking the pointer off the artwork.
Draw: Pencil, Eraser, Bucket, Eyedropper, Line, Rectangle, Ellipse
Select: Rectangle marquee, Ellipse marquee, Lasso, Magic wand, Move
Colour: Shade, Blur, Replace colour, Auto shading
Navigate: Hand, Zoom, Alt loupe, Quick menu
Baking deleted the animation
Baking used to be a one-way door. It wrote each frame's pose into the pixels, deleted every key on the layer and marked the layer as no longer animated, which left nothing to regenerate from.
In pixel art that is close to the worst trade a tool can make. You bake in order to clean frames up by hand, and cleanup is when you find out the timing is wrong. I built the state model before adding any more features.
Four kinds of state
Source art, animation state, generated state and export state are stored separately. Generated frames can be rebuilt, and the original drawing is kept.
Each cel also records where it came from. Painting over a generated frame marks it edited, and the Keep my edits policy leaves any frame with that flag alone. Changing a key marks that layer's generated frames as stale. The editor errs towards stale, since a false alarm costs one regenerate.
Regenerate policies
When you regenerate, the editor asks what to do about frames you have already painted on.
| Policy | What it does |
|---|
| Keep my edits | Regenerates everything except frames marked edited |
| Replace everything | Regenerates the whole range |
| Only empty frames | Fills frames that have no pixels of their own |
| To a new layer | Duplicates the layer first and bakes the copy |
A bake is a single undo step. Undo history has a fixed size and drops the oldest entries first, and one entry per frame would let a long regenerate push out the rest of the session's undo.
Checking the round trip
A four-frame tween of x from 0 to 20, taken through bake, regenerate and unbake. Each number is the x position on that frame.
| Step | Frame 1 | Frame 2 | Frame 3 | Frame 4 |
|---|
| Bake | 8 | 15 | 21 | 28 |
| Endpoint moved to 40, regenerate | 8 | 21 | 35 | 48 |
| Unbake | 8 | 8 | 8 | 8 |
All four keys survive the bake, and the layer is still animated afterwards. A red pixel painted on frame two survives Keep my edits and disappears with Replace everything. Saving and reopening the file brings all of it back.
Rotating without ruining it
Rotating pixel art with nearest-neighbour sampling wrecks it: diagonals turn into uneven staircases and one-pixel details drop out. RotSprite scales the sprite up eight times, rotates the large version, then shrinks each 8×8 block back to one pixel by taking its most common colour.
lit pixels after rotating a one-pixel diagonal by 30 degrees
- Drawn: 32 (the source line)
- RotSprite: 33 (the line holds)
- Nearest neighbour: 25 (the diagonal falls apart)
It invents no colours, because the most common colour in a block is always one that was already in the sprite.
Measured in the editor, using Xenowhirl's RotSprite method
Spacing in whole pixels
At sprite resolution the eye reads motion from how far something moves between frames. Tween positions are rounded to whole pixels, without letting two frames land on the same spot.
- Reads as acceleration. 0 1 2 4 6 9 12
- Reads as a stall. 0 0 0 1 2 4 6
- Raw positions from a tween. 0, 0.2, 0.4, 0.6, 2, 4, 6
- After rounding. 0, 1, 2, 3, 4, 5, 6
Export a game can play
Export is written for the developer who has to play the animation in an engine.
- Sprite sheets. Rows, columns or grids, with padding, margin, integer scale, trim and per-layer rows
- One animation at a time. Any tag, such as walk, exported as its own sheet
- Metadata. Tags with direction, repeats and duration, frame names like walk_0 instead of 12, and the sprite origin reported after trimming and scaling
- Events. Footsteps, hits and spawns are pinned to a frame's id, and they move with the frame when frames are reordered
- GIF and PNG. A GIF89a encoder written for the project, and PNG at 1x or 4x
Rainbow guy, the sample character
A rigged character built from source code instead of a saved file, which keeps him in step with the current file format. Opening a rig that already moves is also the quickest way to learn how one works.
- Seven bones. Each bone is a different hue, and you can see at a glance which part a pose moved
- Tags and events. idle, walk and wave, with blink, two footsteps and greet as events, and his origin on the soles of his feet
- What he exercises. Forward kinematics, rotation-only keys, a full clockwise turn and a bezier overshoot. If a change breaks the transform chain, he visibly comes apart.
The scope on audit day
The animation scope lists 96 numbered requirements. Before planning anything I checked the code against each one, then ordered the work by what depends on what instead of by priority label.
requirements, audited on 8 September 2026
- Shipped: 26
- Partial: 20
- Missing: 50
Since the audit, the state layer, the rest of the core animation work and the first parts of the game export have shipped. The shipped bar is longer today.
The Magic Sprite animation roadmap
Where I pushed back on the scope
Some requirements did not fit this project as written. I changed those in the plan instead of building them to the letter.
- Lua and a CLI. The need behind them is scripting and batch export with nobody at the screen. That survives as a TypeScript API and a headless Node build, and Lua itself does not.
- RotSprite. The scope treats it as a built-in option. It is built into Aseprite, and here it had to be implemented from scratch.
- Tilemaps. Listed as core, although no other requirement depends on them. They come out of this plan and get scoped on their own.
- Palette cycling. The editor stores full RGBA colour and has no indexed mode, so cycling has to swap colours at render time.
What is still open
Risks the roadmap keeps track of.
- File format changes. Transform keys were silently left out of saved files once already, and every pose went with them. The plan is to write the migration test before the next migration.
- Export without a browser. Baking and export both draw through the browser's canvas. Batch export needs a stand-in for it, and its output has to match the interactive export exactly, which two different rasterisers will not produce on their own.
- Panel pressure. The animate panel is 284px wide and already holds seven tabs in three rows. Motion paths, operators and colour tracks each still need a place, and the information architecture has to be settled before any of them arrive.
Open Magic Sprite
Pixel Art, Animation, Creative Tools, Design & Code