technique
Sprite sheets and export
How frames are packed into one PNG with a data file, why padding and pivots and integer camera positions matter, and the pre-flight checklist that keeps pixel art crisp once it is inside the engine.
Before this
This page assumes you are comfortable with:
- prerequisiteRaster images and pixelsWhat a bitmap, a color channel, an alpha value, and an indexed palette actually are, why JPG ruins pixel art, and why scaling by anything but a whole number makes sprites shimmer.
- techniqueSprite animationHow many frames each standard cycle needs, which poses carry a walk, how to time idle and attack animations, and the small tricks that make a handful of frames read as motion.
Why you need this
Most blurry, shimmering, or seam-ridden pixel art was drawn correctly. It was damaged on the way into the game: scaled by a non-integer, sampled with smoothing, or placed at a half-pixel camera position. This page is stage 5 of the pipeline, "ship it", and it is a short list of mechanical rules that are cheap to follow and expensive to discover after launch.
The idea
A sprite sheet (also called an atlas) is one PNG that holds many frames side by side, plus a data file that says where each frame is, how long it shows, and where its anchor point sits. The engine loads one texture instead of hundreds of tiny files, and draws each frame by copying one rectangle out of it. Nothing about the art changes; only the packaging does.
Grid sheets vs packed sheets
A grid sheet puts every frame in a cell of fixed size, one row per animation. A packed sheet trims each frame to its non-transparent bounds and fits the pieces together as tightly as possible, recording each frame's rectangle and its offset from the original cell.
| Grid sheet | Packed sheet | |
|---|---|---|
| Data file | Optional; cell size and row order can be implied | Required; every rectangle is different |
| Wasted space | High; every cell is as big as the largest frame | Low |
| Human readable | Yes; open the PNG and see the rows | No |
| Tooling | None; export from the editor | A packer tool |
| Good for | Small games, tiles, prototyping | Many characters, memory-limited targets |
Start with grid sheets. Move to packed when the texture budget says so.
Padding and edge bleeding
When the engine draws a frame, the graphics hardware samples the texture: it looks up the color at each screen pixel's position in the sheet. Even with nearest-neighbor filtering, a position that lands exactly on the boundary between two frames can read the neighbor's pixel, and any filtering at all averages a frame's edge pixel with the pixel beside it. If the neighbor is a different frame, its edge leaks in as a line of wrong color along the sprite's border.
The fix is padding: 1 pixel (2 is safer with mipmaps) of space between every frame. Either leave it transparent, or extrude: copy the frame's outermost pixels into the padding so a sample that strays lands on the same color it should have read.
Frame A pad Frame B Extruded padding around A
1 1 1 1 1 1 . 4 4 4 4 4 4 1 1 1 1 1 1 1 1
1 2 2 2 2 1 . 4 5 5 5 5 4 1 1 1 1 1 1 1 1
1 2 3 3 2 1 . 4 5 6 6 5 4 1 1 2 2 2 2 1 1
1 2 3 3 2 1 . 4 5 6 6 5 4 1 1 2 3 3 2 1 1
1 2 2 2 2 1 . 4 5 5 5 5 4 1 1 2 3 3 2 1 1
1 1 1 1 1 1 . 4 4 4 4 4 4 1 1 2 2 2 2 1 1
1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1
. transparent padding, 1 to 3 frame A's colors, 4 to 6 frame B's colors.
Without the padding column, a sample at the right edge of frame A that strays by a fraction of a pixel reads 4 and the sprite shows a thin line of frame B's outline color down its right side. Tilesets need the same treatment: tiles drawn edge to edge in a sheet show hairline seams in the game at any zoom, and 1 pixel of extruded padding per tile removes them.
Pivots
A pivot (or anchor) is the point in a frame that the engine places at the sprite's world position. Characters use the point between the feet, so a character at ground level stands on the ground no matter how tall the current frame is. Effects use the center, so an explosion spawned at a point expands equally in every direction.
The pivot must be at the same place on the character across every frame of every animation. If the idle frames' pivot is at the feet and the jump frames' pivot is at the frame center, the character teleports 8 pixels when the jump starts. In a grid sheet this means drawing every frame with the feet on the same row of the cell. In a packed sheet the data file carries a per-frame pivot, and the packer computes it from the untrimmed frame so trimming does not move it.
Naming
One convention, used everywhere: character_animation_direction_frame, lower case, underscores, two-digit frame numbers. knight_walk_east_03 is frame 3 of the knight's east-facing walk. Tools sort these correctly, and a missing frame is obvious in a file list. Directions are north, south, east, west (or up, down, left, right, but pick one set).
The data file
A small JSON shape that covers frame rectangles, durations, and pivots. Coordinates are in sheet pixels, origin top-left, right and down.
{
"image": "knight.png",
"frameSize": [16, 16],
"animations": {
"walk_east": {
"loop": true,
"frames": [
{ "x": 0, "y": 16, "w": 16, "h": 16, "ms": 100, "pivot": [8, 15] },
{ "x": 16, "y": 16, "w": 16, "h": 16, "ms": 100, "pivot": [8, 15] },
{ "x": 32, "y": 16, "w": 16, "h": 16, "ms": 100, "pivot": [8, 15] },
{ "x": 48, "y": 16, "w": 16, "h": 16, "ms": 100, "pivot": [8, 15] }
]
}
}
}
Each frame carries its own ms so uneven holds from Sprite animation survive export. The pivot is the bottom center of a 16 by 16 cell.
Worked example
Integer scaling and the camera
Pixel art is drawn at 1x and displayed at an integer scale: 2x, 3x, 4x. At 3x every art pixel becomes a 3 by 3 block of screen pixels, and every art pixel is the same size. At 2.5x, some art pixels become 2 screen pixels wide and some become 3, and which ones changes as the sprite moves. That is sub-pixel shimmer: edges crawl and thin lines flicker.
The same thing happens with an integer scale if a sprite is placed at a fractional position. A 16-pixel sprite at , scaled 3x, starts at screen pixel 31.2; the hardware rounds or blends, and the sprite's left edge lands one screen pixel differently from the sprite beside it.
The fix has two parts, and most projects get only the first:
- Round every sprite's position to a whole art pixel before drawing:
Math.round(x). - Round the camera to a whole art pixel too. If the camera sits at and every sprite is on an integer, every sprite is drawn at a half pixel anyway.
// world position -> screen position, at an integer display scale
function toScreen(worldX, worldY, camX, camY, scale) {
const cx = Math.round(camX), cy = Math.round(camY); // round the camera
const sx = Math.round(worldX) - cx; // then the sprite
const sy = Math.round(worldY) - cy;
return [sx * scale, sy * scale]; // integer times integer
}
console.log(toScreen(10.4, 20.6, 100.5, 0, 3)); // [-273, 63]
Movement can still be smooth: keep positions as decimals in the simulation, and round only in the draw step. A character moving 0.4 pixels per frame at 60 fps is drawn on the same pixel for two or three frames and then steps one pixel; at 3x that step is 3 screen pixels and looks like pixel art moving, which is what it is.
Nearest-neighbor filtering must be on for every pixel-art texture. The default in most engines is linear filtering, which averages neighboring texels and turns every sprite into a blur. It is one flag per texture, set at load time, and forgetting it is the single most common export failure.
Power-of-two textures: some hardware and some engines want texture dimensions that are powers of two (256, 512, 1024), so pad the sheet out to the next power of two if the target asks for it, and otherwise ignore this rule.
Pre-flight checklist
| Check | Symptom if wrong |
|---|---|
| Display scale is an integer (2x, 3x, 4x) | Uneven pixel sizes; edges crawl as sprites move |
| Nearest-neighbor filtering on every texture | Blurry sprites |
| Camera position rounded to whole art pixels | Everything shimmers together while scrolling |
| Sprite positions rounded to whole art pixels | Individual sprites shimmer |
| 1 px (or more) padding between frames, extruded or transparent | Colored hairlines on sprite edges |
| Tiles padded the same way | Seams between tiles |
| Pivots consistent across every frame of a character | Character jumps when the animation changes |
| PNG, not JPG | Smeared colors and blotches around every edge |
JPG compresses by blending neighboring colors, which destroys pixel art on contact. PNG is lossless; indexed PNG (a palette plus one byte per pixel) is smallest.
In a game's art pipeline
Stage 5, ship it. Everything drawn in stages 2 through 4 passes through here: character frames from Sprite animation, effect frames from VFX and particles with center pivots, tilesets from Tileable textures and tilesets with per-tile padding, and UI from UI and text in pixel art, which must sit at the same integer scale as the world. The display scale itself was decided in stage 1 (Pixel art fundamentals); this page makes the engine honor it. Purchased packs arrive as sheets built to someone else's padding and pivot rules, and Adapting purchased packs repacks them to yours.
Common mistakes
- Linear filtering left on. Every sprite is soft and the outlines are gray. One flag.
- Camera not rounded. Each sprite is on an integer, the camera is at 100.5, and the whole scene shimmers while scrolling. Round the camera first.
- Frames touching in the sheet. A 1-pixel line of a neighbor's color runs down one side of a sprite, but only at some positions. Pad.
- Pivot at the frame center for characters. The character sinks into the floor on tall frames and floats on short ones.
- Mixed cell sizes in one grid sheet. The attack frames needed 24 pixels and were squeezed into 16-pixel cells, so the sword is clipped on the strike frame. Use a bigger cell for the whole character, or pack.
- Saving as JPG. Blotchy halos around every outline. Undoable only by redrawing.
Cost
Packing is a one-time tool step and costs nothing per frame. The recurring cost is texture memory: a texture in memory is uncompressed, 4 bytes per pixel for RGBA (red, green, blue, alpha). A 1024 by 1024 RGBA sheet is bytes, 4 MB, regardless of how small the PNG was on disk. A 2048 by 2048 sheet is 16 MB. A phone game with a 64 MB texture budget holds sixteen 1024 sheets, which sounds like plenty until each of 8 characters gets its own sheet and every level its own tileset. Grid sheets with big cells and lots of transparent space spend that budget fastest; packing recovers half or more. Padding costs 2 pixels per frame edge, under 15 percent of a 16-pixel grid sheet, and is never the thing to cut.
Going further
- Raster images and pixels, for what a texel, an alpha channel, and an indexed PNG are.
- Tileable textures and tilesets, for the tile-edge padding rule in more depth.
- Pixel art fundamentals, for choosing the display scale from the target screen size.
- UI and text in pixel art, for keeping the interface at the same scale as the world.
- Adapting purchased packs, for repacking a sheet built to different rules.