/ Game Dev With AI / Aseprite Tutorial: Sprites and Animation for Godot
Game Dev With AI • • 14 min read

Aseprite Tutorial: Sprites and Animation for Godot

An Aseprite tutorial that ends inside Godot 4.7. Draw a 32 by 32 character, tag two animations, export a sheet with JSON, and load it with a short loader.

Aseprite Tutorial: Sprites and Animation for Godot

This Aseprite tutorial ends with a sprite animating inside Godot 4.7, which is the part every other Aseprite tutorial stops short of. You'll draw a 32 by 32 character on two layers, animate an idle and a walk as tagged frame ranges, export one packed sheet plus a JSON file from the command line, and load it with a 30 line GDScript loader that turns each tag into an animation and keeps the per-frame timing you set in Aseprite. Along the way there are three Godot project settings that decide whether pixel art looks crisp or blurry, and none of them is on by default.

Aseprite costs $19.99 as a minimum pledge, per its FAQ, or nothing if you compile the source yourself. The license details are in the first table because they matter more than most tutorials admit. Every shortcut and CLI flag below was checked against the Aseprite docs today, and every Godot call against the 4.7 class reference.

What Aseprite Costs and What You Own

The FAQ is unusually direct about money and rights, so this table is mostly quotes.

Question Answer from the FAQ or README
Price "$19.99 /USD is the minimum pledge amount" for the 1.x binaries; pledge more if you want
What you get Windows, macOS and Ubuntu builds, "A Steam key to get automatic updates," and "Updates up to v1.9"
Can you sell games made with it "Sure you can!" Your sprites are yours "in any product (personal or commercial)"
Is it open source Source is available and you "can still download its source code, compile it, and use it," including for commercial art. It was GPLv2 until August 2016; now it's an EULA
The one restriction "you cannot redistribute Aseprite to third parties"
Several machines Copy it "as many times as you want on your computers"; a company needs "one license for each developer"
Apple Silicon a universal binary, "runs on Intel-based and Apple Silicon Macs natively"
.ase or .aseprite "It's the same difference between .jpg and .jpeg"

My read on the compile route is that it is real and it is a rabbit hole. If your time is worth anything, twenty dollars for signed builds and a Steam key is the cheaper path, and I'd rather you spent the evening drawing. The trial version on the home page exists if you want to confirm the tool fits your hands first.

Five Words the Docs Use

Aseprite's basics page defines the model in one sentence. "In Aseprite, a sprite consists of a sequence of frames and a stack of layers. The intersection of frames and layers creates an array of editable graphic cels."

So a sprite is a grid. Frames run left to right along the timeline and each one is "a single still image." Layers stack bottom to top and each frame's picture is the stack composited. A cel is one cell of that grid, one layer on one frame, and it can be moved or cleared without touching anything else. A tag, per the tags page, "indicates an animation of your sprite," a named range of frames with a direction of forward, reverse or ping-pong.

That last word is the one Godot cares about, and the one I'd underline. Tags are how one file holds every animation a character needs, and the JSON export carries them across.

Draw the Character

File, New, 32 by 32, RGBA colour mode. Aseprite also offers indexed mode with palettes up to 256 colours, per the README, and if you're aiming for a strict palette that is the better choice, but RGBA is fine for a first sprite and Godot does not care which you used.

The shortcuts the basics page tells you to learn from the start are the ones you'll actually use. Keys 1 through 6 set the zoom. B is the pencil, M is the rectangular marquee. Alt plus click samples a colour with the eyedropper, left for foreground, right for background. Tab hides and shows the timeline, and the docs note that if the timeline is ever missing, Tab is the fastest way back. Space plus drag pans.

Make two layers with Shift+N, Body underneath and Outline on top. Draw the body as a filled silhouette first, then the outline on its own layer. I keep them separate because it makes the walk cycle cheap later. You'll move the body cel a pixel and redraw only the parts of the outline that changed.

Two settings to know about before you get frustrated. The pencil's pixel-perfect mode, which the home page describes as creating "perfect strokes for pixel-art," removes the doubled corner pixels that a freehand line produces. And if everything looks huge, that is by design. The FAQ says "Each Aseprite pixel uses 2x2 real pixels on the screen" to mimic old sprite editors, and Edit, Options, Screen Scale 1:1 changes it after a restart.

Save as .aseprite. The docs say that format preserves "all your image information (layers, frames, etc.)" where a PNG flattens everything.

Animate Idle and Walk

The animation page gives the loop. Draw the first frame, add frames with Alt+N, move between them with the left and right arrow keys, preview with Enter, tag ranges.

For the idle, four frames. Frame one is the pose you drew. Alt+N copies it into frame two; nudge the Body cel down a pixel with the move tool. Frames three and four go back up. Set each frame's duration to 100 ms. The frame duration page gives two routes, Frame, Frame Properties with the P key for the current frame, or select a range in the timeline, right-click, and choose Properties to set them all at once. Then select the four frames and press F2 twice. The tags page says the first press creates a tag and the second opens its properties, so name it idle, set the direction to ping-pong, and Aseprite will bounce through the frames rather than snapping from four back to one.

For the walk, six frames after the idle, 80 ms each, tagged walk, direction forward. Six is my habit, not a rule; four reads as a shuffle and eight is more drawing than a first character deserves. This is where onion skinning earns its keystroke. F3 turns it on, per the onion skinning page, and shows the previous and next frames tinted so you can see where a leg was before you draw where it goes.

The frame durations are not decoration. They travel into the JSON and the Godot loader below honours them, so an 80 ms walk step stays an 80 ms walk step in the engine without you re-entering anything.

Export the Sheet Godot Can Read

The dialog route is File, Export Sprite Sheet, where the sprite sheet page says you "can select all visible layers or a certain layer, and select the frames based on tags." Tick the JSON data output and pick the array format, which I'd do even for a one-off export because the loader below depends on the order.

The command line route is the one I'd set up on day one, because it means re-exporting is a keypress rather than a dialog every time you fix a frame. The CLI docs describe --batch, or -b, as running Aseprite "only to process command line options, then finishes."

aseprite -b hero.aseprite --sheet hero.png --data hero.json --format json-array --sheet-type packed --list-tags

Each flag does one thing, and each is quoted from the CLI page.

Flag What it does
--sheet hero.png "Exports all images specified in the command line before the --sheet option" into one PNG
--data hero.json "Saves information about the exported sprite sheet in a JSON format"
--format json-array frames as an ordered list; the default json-hash keys them by filename, which loses the order the loader needs
--sheet-type packed "a special packing algorithm to avoid waste of space"
--list-tags "When used with --data, the tags will be available in the JSON output in the meta attribute"

Do not add --trim. It "removes borders from sprites/layers/cels before save them," which shrinks each frame to its visible pixels and shifts them. The loader below reads only the frame rectangle, so trimmed frames would jump around. Padding flags such as --inner-padding are safe if you ever see bleeding between frames.

The JSON that comes out has a frames array where every entry carries a frame rectangle with x, y, w and h, and a duration in milliseconds, and a meta object whose frameTags list has a name, from and to per tag. That is everything the loader uses.

Load It in Godot Without a Plugin

Copy hero.png and hero.json into your Godot project. Then add this script as aseprite_frames.gd.

class_name AsepriteFrames
extends RefCounted


static func build(sheet: Texture2D, json_path: String) -> SpriteFrames:
    var frames := SpriteFrames.new()
    var data: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(json_path))
    var frame_list: Array = data["frames"]
    var meta: Dictionary = data["meta"]
    var tags: Array = meta.get("frameTags", [])
    if frames.has_animation(&"default"):
        frames.remove_animation(&"default")
    for tag: Dictionary in tags:
        var anim := StringName(tag["name"])
        frames.add_animation(anim)
        frames.set_animation_speed(anim, 1000.0)
        var direction: String = tag.get("direction", "forward")
        if direction == "pingpong":
            frames.set_animation_loop_mode(anim, SpriteFrames.LOOP_PINGPONG)
        else:
            frames.set_animation_loop_mode(anim, SpriteFrames.LOOP_LINEAR)
        for i in range(int(tag["from"]), int(tag["to"]) + 1):
            var entry: Dictionary = frame_list[i]
            var rect: Dictionary = entry["frame"]
            var texture := AtlasTexture.new()
            texture.atlas = sheet
            texture.region = Rect2(rect["x"], rect["y"], rect["w"], rect["h"])
            frames.add_frame(anim, texture, float(entry["duration"]))
    return frames

Use it from any scene with an AnimatedSprite2D child.

extends CharacterBody2D

@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D


func _ready() -> void:
    sprite.sprite_frames = AsepriteFrames.build(preload("res://art/hero.png"), "res://art/hero.json")
    sprite.play(&"idle")

Three decisions in that loader are worth understanding, because they are the ones I'd expect to get wrong writing it from scratch.

The animation speed is set to 1000 frames per second and each frame's duration is passed in milliseconds. That looks like a bug until you read the SpriteFrames reference, which says add_frame takes a relative duration and gives the formula "absolute_duration = relative_duration / (animation_fps * abs(playing_speed))". At 1000 fps a relative duration of 80 is 80 divided by 1000, which is 0.08 seconds. Your Aseprite timing survives exactly.

Each frame is an AtlasTexture, which the reference describes as a texture "that draws only part of its atlas texture, as defined by the region," and it notes that many of them can be cropped from the same atlas. One PNG in memory, one region per frame.

The loop mode comes from the tag's direction. The CLI docs' example only shows name, from and to on a tag, so the loader reads direction with a default of forward and maps ping-pong onto LOOP_PINGPONG. If your export includes the field, the idle bounces the way it did in Aseprite. If it doesn't, you get a plain loop and nothing breaks. Reverse is left as a plain loop too, because I'd rather keep the loader short than handle a direction I don't use.

The official 2D sprite animation tutorial covers the manual alternative, which is the SpriteFrames panel's "Add frames from a Sprite Sheet" button where you type the horizontal and vertical counts and click frames. It works, and for one animation I'd use it. The moment you have four animations and you're still editing the art, clicking frames in a grid after every export is the step you'll skip, and the loader is how you stop skipping it. The Godot 2D tutorial on this site is where this character replaces the placeholder square.

The Three Settings That Keep Pixels Square

Nothing above will look right until you change these, and the docs are explicit that the defaults are for a different kind of game. My order is the filter first, because it is the one you see the instant the scene runs.

Setting Where Why
Default Texture Filter to Nearest Project Settings, Rendering, Textures, Canvas Textures the project default is Linear, which "blends between the nearest 4 pixels" and turns a 32 pixel sprite to mush; Nearest "reads from the nearest pixel only"
Stretch Mode to viewport Project Settings, Display, Window, Stretch the docs mark this one "Recommended for games that use a pixel art aesthetic"; canvas_items, the default for new 4.7 projects, means "there is no longer a 1:1 correspondence between sprite pixels and screen pixels"
Scale Mode to integer same section "the screen size will always be an integer multiple of the base viewport size. This provides a crisp pixel art appearance"

Two more are optional. rendering/2d/snap/snap_2d_transforms_to_pixel and its vertices twin are, in the docs' words, "Useful for low-resolution pixel art games" and give "a crisper appearance at the cost of less smooth movement." I'd leave them off until a moving sprite shimmers, then turn on transforms first.

On import, leave the compression at Lossless. The importing images page says it "is also the recommended setting for pixel art," and warns that if a texture is ever used in a 3D scene, Detect 3D switches it to VRAM compression, which is where a pixel art sprite in a 3D UI suddenly goes grainy.

When It Does Not Work

Symptom Likely cause Check
Sprite is blurry texture filter still Linear the project setting, or texture_filter on the node
Animation plays at the wrong speed speed not 1000 or durations not in ms the two numbers in the loader
Frames are in a random order json-hash export re-export with --format json-array
frameTags is missing no --list-tags on the command line add it and re-export
Frames jitter or shift --trim was on drop it; the loader ignores spriteSourceSize
Error about assigning null to a Dictionary the JSON path is wrong or the file is not in the project the path string, and that hero.json sits inside res://
Idle does not bounce the export has no direction field set the loop mode by hand for that one tag, or accept the plain loop
Sprite is crisp in the editor and soft in the game stretch mode canvas_items switch to viewport with integer scaling
Pixels look fine on one monitor and smeared on another window size not a multiple of the base size integer scale mode, and set the window minimum size so it can't shrink below the base

Is Aseprite free. There is a trial, the binaries are $19.99 minimum, and the source compiles for free under an EULA whose only restriction is that you can't redistribute what you built. So no, and also yes, depending on how you value an evening. I'd pay.

Where is the Aseprite GitHub. At github.com/aseprite/aseprite, and its README explains the three licenses, the EULA for the source and binaries, an educational license on request, and the Steam Subscriber Agreement for the Steam build.

Is there a PDF or a step by step. The official docs are the closest thing to a manual, and they are short pages rather than a book. This page is the step by step for the one path most tutorials skip, the export and the load.

What about AI-generated sprites. They still end up in Aseprite for cleanup, and the AI sprite generator reality check on this site covers why. A generated walk cycle with inconsistent pixel sizes is more work to fix than six frames drawn over an onion skin.

What to Draw Next

One character, two animations, one loader. That is enough to replace the square in your prototype and see whether the game reads better with a face on it. If it does, the 2D game scoping guide here has the argument for listing every sprite you need before you draw a third one, and the first Godot game guide covers the editor side if AnimatedSprite2D is still a new word.

The pipeline is the point. The moment aseprite -b and a reload gets your fix into the running game in under a minute, you'll draw more, and the drawing is where the game starts to look like yours.