A sprite sheet is one image with many frames on it. The image is the easy part. The hard part is the data that says where each frame is, how long it lasts, and which frames make up “walk”. Every tool has its own way of writing that down, and most import problems come from a mismatch between what one tool wrote and what the next one expects.
This is a field guide to the formats you’ll actually run into, with minimal real examples of each, so you can recognise a file when you open it and know what your engine needs.
Two kinds of sheet
Grid sheets (strips)
Every frame is the same size and sits in a fixed grid, often one animation per row. The only metadata you need is:
- frame width and height,
- columns and rows (or frame count),
- optionally margin (around the sheet) and spacing (between frames),
- the frame rate, and which row is which animation.
That’s little enough to type into an import dialog, which is why grids work everywhere: Godot’s “Add frames from sprite sheet”, Unity’s Grid By Cell Size, Phaser’s load.spritesheet, GameMaker’s strip import, Aseprite’s Import Sprite Sheet.
The cost is wasted space: every frame takes the size of the largest one, so a sword swing that sticks out on two frames makes all frames wide. For a few characters that doesn’t matter. For hundreds of animations on mobile it might.
A real grid strip, ten equal 63 × 84 cells:
Packed atlases
Frames are packed tightly in any arrangement, often trimmed (transparent borders cut away) and sometimes rotated 90° to fit better. Because nothing can be inferred from position, an atlas always comes with a data file listing every frame’s rectangle, plus enough information to put trimmed frames back in the right place.
Atlases save memory and let you put many characters, UI pieces and effects in one texture, which means fewer draw calls. They need tooling: you don’t edit an atlas by hand.
The common JSON atlas format (TexturePacker style)
TexturePacker’s JSON became a de facto standard. Phaser, PixiJS and many other tools read it, and Aseprite writes it. It comes in two variants that differ only in how frames is stored.
JSON Hash: frames is an object keyed by frame name.
{
"frames": {
"walk_down_000.png": {
"frame": { "x": 2, "y": 2, "w": 38, "h": 60 },
"rotated": false,
"trimmed": true,
"spriteSourceSize": { "x": 13, "y": 4, "w": 38, "h": 60 },
"sourceSize": { "w": 64, "h": 64 },
"pivot": { "x": 0.5, "y": 1 }
}
},
"meta": {
"app": "https://www.codeandweb.com/texturepacker",
"version": "1.0",
"image": "hero.png",
"format": "RGBA8888",
"size": { "w": 512, "h": 256 },
"scale": "1"
}
}
JSON Array: frames is a list, and each entry carries its own filename.
{
"frames": [
{
"filename": "walk_down_000.png",
"frame": { "x": 2, "y": 2, "w": 38, "h": 60 },
"rotated": false,
"trimmed": true,
"spriteSourceSize": { "x": 13, "y": 4, "w": 38, "h": 60 },
"sourceSize": { "w": 64, "h": 64 }
}
],
"meta": { "image": "hero.png", "size": { "w": 512, "h": 256 }, "scale": "1" }
}
What the fields mean:
| Field | Meaning |
|---|---|
frame | Where the frame is in the atlas image (after trimming), in pixels from the top-left |
rotated | true if the packer turned the frame 90° clockwise to fit; frame.w/h are then the unrotated size |
trimmed | true if transparent borders were removed |
spriteSourceSize | Where the trimmed rectangle sits inside the original frame. x: 13, y: 4 means “draw this 13 px right and 4 px down from the original frame’s corner” |
sourceSize | The original, untrimmed frame size. Use this for layout so trimmed frames don’t jitter |
pivot | Optional normalised anchor (0–1). {x: 0.5, y: 1} is bottom centre |
meta.image | The atlas image file name, relative to the JSON |
meta.scale | Scale the atlas was exported at; used for @2x variants |
Array preserves frame order explicitly; Hash relies on the JSON object’s key order, which most parsers keep but don’t strictly guarantee. If a tool offers both, Array is the safer choice for animations.
Trimming and jitter
If your engine ignores spriteSourceSize and sourceSize, trimmed frames are drawn from their trimmed corner, so the character shifts a few pixels every frame. If you see that, either make the importer respect those fields or export without trimming.
Aseprite’s JSON
Aseprite’s File → Export Sprite Sheet writes the same structure (Hash or Array), with additions that matter for animation:
{
"frames": [
{
"filename": "hero 0.aseprite",
"frame": { "x": 0, "y": 0, "w": 64, "h": 64 },
"rotated": false,
"trimmed": false,
"spriteSourceSize": { "x": 0, "y": 0, "w": 64, "h": 64 },
"sourceSize": { "w": 64, "h": 64 },
"duration": 100
}
],
"meta": {
"app": "https://www.aseprite.org/",
"version": "1.3.x",
"image": "hero.png",
"format": "RGBA8888",
"size": { "w": 512, "h": 256 },
"scale": "1",
"frameTags": [
{ "name": "walk_down", "from": 0, "to": 7, "direction": "forward" },
{ "name": "walk_left", "from": 8, "to": 15, "direction": "forward" }
],
"layers": [{ "name": "Layer 1", "opacity": 255, "blendMode": "normal" }],
"slices": []
}
}
durationis per frame, in milliseconds. Aseprite lets each frame have its own timing (a long anticipation frame before a fast swing), which a single fps value can’t express.frameTagsname ranges of frames.directionisforward,reverseorpingpong(newer versions also havepingpong_reverse). Tags are how one file holds a whole character.layersandslicesappear when you enable them in the export options. Slices are named rectangles, often used for hitboxes or 9-slice UI.
From the command line, the same export looks like this (useful in a build script):
aseprite -b hero.aseprite \
--sheet hero.png --data hero.json \
--format json-array --sheet-type packed \
--list-tags --list-slices
Add --split-tags with a {tag} placeholder in the output name to get one file per tag, or --trim to trim frames. Check aseprite --help for your version, since flags are added over time.
Phaser can load this directly with this.load.aseprite() and create every tagged animation with this.anims.createFromAseprite(); see Phaser 3 sprite sheet animation. Other engines need a plugin or a small script that turns frameTags into animations.
Godot 4 SpriteFrames (.tres)
Godot doesn’t import JSON atlases natively. It stores animations in a SpriteFrames resource, played by AnimatedSprite2D. You can build one in the editor, or ship it as a text resource (.tres) alongside the sheet. A minimal Godot 4 SpriteFrames with one looping animation referencing regions of a single sheet:
[gd_resource type="SpriteFrames" load_steps=4 format=3]
[ext_resource type="Texture2D" path="res://sprites/hero_walk.png" id="1_sheet"]
[sub_resource type="AtlasTexture" id="AtlasTexture_0"]
atlas = ExtResource("1_sheet")
region = Rect2(0, 0, 64, 64)
[sub_resource type="AtlasTexture" id="AtlasTexture_1"]
atlas = ExtResource("1_sheet")
region = Rect2(64, 0, 64, 64)
[resource]
animations = [{
"frames": [{
"duration": 1.0,
"texture": SubResource("AtlasTexture_0")
}, {
"duration": 1.0,
"texture": SubResource("AtlasTexture_1")
}],
"loop": true,
"name": &"walk_down",
"speed": 10.0
}]
Things to know:
load_stepsis the number of external plus sub resources plus one. Godot fixes it up when it saves the file, so an off-by-one just gets corrected.- Each frame is an
AtlasTexturepointing at aregionof the sheet, so the sheet is loaded once. speedis frames per second; each frame’sdurationis a multiplier on that, not milliseconds. Adurationof 2.0 holds a frame twice as long. That’s how Aseprite-style variable timing maps onto Godot.&"walk_down"is a StringName literal. Godot 3’s format (format=2) is different and not interchangeable.- The
ext_resourcepath isres://-absolute. Move the PNG outside the editor and Godot asks you to fix the dependency.
AI Sprite Maker’s Godot export writes this format with one animation per direction. Setup and code are in Using our sheets in Godot and How to import a sprite sheet in Godot 4.
Unity
Unity keeps sprite rectangles in the texture’s .meta file, not in a separate data file. When you slice in the Sprite Editor, Unity writes each sprite’s rect, pivot and name into the meta. Animations are separate .anim clips that reference those sprites.
So “a Unity sprite sheet” is really the PNG, its .meta, and the clips. To import a JSON atlas you need an importer (TexturePacker ships one) or an editor script that reads the JSON and writes sprite rects through the 2D Sprite package’s data provider. Remember that Unity rects start at the bottom-left of the texture while JSON atlases start at the top-left. Details in Sprite sheets in Unity.
GIF, APNG and WebM are previews, not engine formats
Animated GIFs are great for showing an animation in a store page, a tweet or a Discord message. They’re a poor source format: 256 colours per frame, 1-bit transparency (edges get jaggies or halos), frame delays in hundredths of a second, and no metadata about directions or names. APNG and WebP fix colour and transparency but are still single animations with no game metadata. WebM with alpha is good for large effects in some engines, not for pixel-art sprites.
Keep the PNG sheet as your source of truth and generate GIFs from it. If all you have is a GIF, the free GIF to Sprite Sheet tool unpacks it into a clean sheet.
Which engine reads what
| Engine / framework | Grid sheet | JSON atlas (TexturePacker style) | Aseprite JSON | Native animation data |
|---|---|---|---|---|
| Godot 4 | Yes (SpriteFrames editor, Sprite2D hframes/vframes) | Via plugin or script | Via plugin or script | SpriteFrames .tres |
| Unity | Yes (Sprite Editor grid slice) | Via TexturePacker importer or script | Via Aseprite Importer for .aseprite files | .meta + .anim |
| Phaser 3 | load.spritesheet | load.atlas (Hash and Array) | load.aseprite + createFromAseprite | Animation Manager config |
| PixiJS | Via a small script | Assets.load spritesheet JSON | Mostly (it’s the same frame format; tags need code) | animations key in the JSON |
| GameMaker | Strip import (_stripN names) | Not natively | Not natively | Sprite resources |
| Defold | Tile source | Via extension | Via extension | Atlas / tile source animations |
| Construct 3 | Import from sprite strip | Not natively | Not natively | Animations editor |
| LÖVE | Quads from a grid (e.g. anim8) | Via a library | Via a library | None built in |
The safe default is still a grid PNG with a small JSON sidecar (frame size, columns, rows, fps, row names). Every engine can load it, and every format above can be generated from it.
Padding, extrusion and texture size
Three settings in packers that matter more than they look:
- Padding (or shape padding): empty pixels between frames. With nearest-neighbour filtering and integer scaling you can get away with 0. Add 2 px if your game ever scales by non-integer amounts, rotates sprites, or uses mip maps, or neighbouring frames bleed in as thin lines.
- Extrude: repeats each frame’s edge pixels outward by 1 px. It fixes bleeding at the edges of tiles that touch, where padding alone shows a gap.
- Max texture size and power of two: older GPUs and some mobile compression formats want power-of-two textures (256, 512, 1024…). Most modern targets don’t care, but staying at or under 2048 × 2048 per atlas is still a sensible default for mobile and web.
Converting between formats
You rarely need a paid tool for this:
- Loose frames → grid sheet + JSON: the free Sprite Sheet Maker.
- Any sheet → frames (or a ZIP): the free Sprite Sheet Slicer. Slice, then re-pack in whatever layout your engine wants.
- GIF or video → sheet: GIF to Sprite Sheet and Video to Sprite Sheet.
- Aseprite → anything: the CLI above, from a build script.
If you generate sprites with AI Sprite Maker, every job can be exported as a PNG sheet, PNG + JSON, Godot SpriteFrames, Aseprite-style JSON with a tag per direction, a GIF preview or a frames ZIP, so you can pick the format for your engine instead of converting. More in Exporting sprites to your engine.