Godot 4 changed enough about 2D textures that a lot of Godot 3 tutorials now send you the wrong way. The biggest change: texture filtering is no longer an import option. If you go looking for the old “Filter” checkbox in the Import dock, you won’t find it, and your 32×32 knight stays blurry.
This guide sets up a Godot 4 project for pixel art, imports a sprite sheet into AnimatedSprite2D, plays it from GDScript, and covers the other ways to do the same thing: Sprite2D with an AnimationPlayer, building SpriteFrames in code, and loading a ready-made .tres. It finishes with the problems that eat the most time: blur, seams between frames, and animations that play in the wrong order.
Everything here was checked against Godot 4.3 and 4.4. The settings exist in 4.2 too, except where noted.
Set up the project for pixel art first
Do this before you import anything. These settings apply to every sprite in the project, so you won’t have to fix them node by node later.
1. Default texture filter: Nearest
Open Project → Project Settings, turn on Advanced Settings (top-right toggle), and go to Rendering → Textures → Canvas Textures → Default Texture Filter. Set it to Nearest.
The setting path in project.godot is rendering/textures/canvas_textures/default_texture_filter. With it set to Nearest, every CanvasItem that leaves its own texture_filter on Inherit samples textures without smoothing.
If you only want some nodes crisp (say, a painted HD background with pixel characters on top), leave the project default alone and set CanvasItem → Texture → Filter to Nearest on the nodes that need it. Children inherit it, so setting it on a parent Node2D covers the whole character.
2. Snap 2D to whole pixels
Still in Project Settings, under Rendering → 2D → Snap, turn on:
- Snap 2D Transforms to Pixel (
rendering/2d/snap/snap_2d_transforms_to_pixel) - Snap 2D Vertices to Pixel (
rendering/2d/snap/snap_2d_vertices_to_pixel)
Without these, a character moving at 37.4 pixels per second sits on fractional positions, and nearest filtering picks a different source pixel on alternate frames. That’s the “shimmering” or “wobbling eyes” effect people blame on their art. The snap settings fix it at render time while your physics positions stay precise.
Watch out Pixel snapping can make slow camera follow look steppy. If that bothers you, keep snapping on and smooth the camera with Camera2D’s position smoothing, rather than turning snapping off.
3. Stretch mode and integer scaling
Pick a base resolution that suits your art. 320×180 and 480×270 both scale cleanly to 1080p. Set it in Display → Window → Size → Viewport Width / Height, then under Display → Window → Stretch:
| Setting | Value | Why |
|---|---|---|
| Mode | viewport | Renders the game at the base resolution, then scales the image. Every pixel stays square and the same size. |
| Mode (alternative) | canvas_items | Renders at window resolution. Text and UI stay sharp, but sprites can be positioned between “art pixels”. |
| Aspect | keep | Letterboxes instead of stretching. |
| Scale Mode | integer | Only scales by 2×, 3×, 4×. Added in Godot 4.2. |
For strict retro pixel art, viewport plus integer is the safe choice. For a game with lots of UI text, canvas_items is often nicer. Many games ship viewport for the game world and a separate high-resolution CanvasLayer for UI.
For testing, set Window Width/Height Override (in the same Window section) to something like 1280×720 so the editor’s play window isn’t tiny.
Import the sprite sheet
Drop the PNG into your project folder (for example res://characters/knight/knight_walk.png). Select it in the FileSystem dock and open the Import dock.
The defaults are mostly right for 2D:
- Import As: Texture2D
- Compress → Mode: Lossless. Leave it. VRAM-compressed formats damage small pixel art.
- Mipmaps → Generate: Off. Mipmaps on a pixel sheet cause bleeding between frames when the sprite is scaled down.
- Process → Fix Alpha Border: On (default). This fills fully transparent pixels next to opaque ones with the neighbouring colour, which stops dark fringes if anything does get filtered.
If you change anything, click Reimport. To make your settings the default for future PNGs, use the Preset menu in the Import dock and choose Set as Default for ‘Texture2D’.
Tip Before you import, check the sheet. You need to know the frame size, the number of columns and rows, and whether there is padding between frames. If the file came with JSON metadata (ours does, Aseprite does), the numbers are in there. Otherwise open it in the free Animation Previewer and adjust the grid until it plays cleanly.
This is the kind of sheet the rest of this guide uses: one row, eight 51 × 68 frames, numbered the way the SpriteFrames editor numbers them when you click cells in Add Frames from Sprite Sheet (Horizontal 8, Vertical 1).
AnimatedSprite2D and the SpriteFrames editor
AnimatedSprite2D is the quickest way to get a character moving. It plays animations stored in a SpriteFrames resource, and that resource has a built-in editor with a sheet slicer.
- Add an AnimatedSprite2D node to your character scene, as a child of the
CharacterBody2D. - In the Inspector, click Sprite Frames → [empty] → New SpriteFrames. Click the new resource to open the SpriteFrames panel at the bottom of the editor.
- A new SpriteFrames already has an animation named
default. Rename it toidleorwalk(double-click the name), or add new ones with the Add Animation button. - With the animation selected, click Add frames from a Sprite Sheet (the grid icon in the frames toolbar) and pick your PNG.
- In the dialog, set Horizontal and Vertical to the number of columns and rows. If your frames have gaps or a margin, open the Size, Separation and Offset fields and enter them in pixels.
- Click frames in the order they should play. Selection order is play order. Select/Clear All Frames at the top selects the whole grid in reading order.
- Click Add N Frame(s).
Then set the speed (FPS field next to the animation list) and the Loop toggle. A walk usually loops at 8–12 FPS. A death or attack animation should not loop.
If one sheet holds several animations, one per row, repeat steps 3–7 for each animation and select only that row’s frames each time. That’s fiddly for an 8-direction character with eight rows per motion, which is why building SpriteFrames in code (below) is worth knowing.
Per-frame duration
Each frame in a SpriteFrames animation has a duration multiplier, default 1.0. A frame with duration 2.0 holds twice as long. This is how you get a heavier attack: hold the wind-up frame for 3, snap through the swing at 1, and hold the follow-through at 2, all at a single FPS setting. Select a frame in the panel and edit Frame Duration in the toolbar.
Play it from GDScript
A minimal top-down controller that picks idle or walk and flips the sprite for left/right:
extends CharacterBody2D
@export var speed := 80.0
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
func _physics_process(_delta: float) -> void:
var input := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
velocity = input * speed
move_and_slide()
if input.is_zero_approx():
sprite.play("idle")
else:
sprite.play("walk")
if input.x != 0.0:
sprite.flip_h = input.x < 0.0
play() is safe to call every frame. If the requested animation is already playing, Godot leaves it running instead of restarting it. If you need to restart a looping animation from frame 0, call stop() first, or set sprite.frame = 0.
For one-shot animations, connect to animation_finished. It fires when a non-looping animation reaches its last frame:
var attacking := false
func attack() -> void:
attacking = true
sprite.play("attack")
func _ready() -> void:
sprite.animation_finished.connect(_on_animation_finished)
func _on_animation_finished() -> void:
if sprite.animation == "attack":
attacking = false
sprite.play("idle")
Guard your movement code with if attacking: return (or a proper state machine) so the walk logic doesn’t overwrite the attack on the next physics frame. This is the single most common “my attack animation only shows one frame” bug.
Two more AnimatedSprite2D details:
sprite.speed_scalemultiplies the playback speed. Tie it to movement speed so the feet don’t slide when the character walks slowly:sprite.speed_scale = velocity.length() / speed.- The
frame_changedsignal fires on every frame change. Use it for footstep sounds or to spawn a hitbox on the exact frame the sword connects:if sprite.animation == "attack" and sprite.frame == 3: enable_hitbox().
Choosing a direction for 4 or 8-direction sprites
For top-down games you’ll have one animation per direction, such as walk_s, walk_se, walk_e. Godot’s Vector2.angle() measures from the positive X axis, and because Y points down in 2D, a positive angle turns clockwise on screen. Dividing the circle into eight 45° slices gives this order:
const DIRS_8 := ["e", "se", "s", "sw", "w", "nw", "n", "ne"]
const DIRS_4 := ["e", "s", "w", "n"]
var facing := "s"
func dir_8(v: Vector2) -> String:
var i := wrapi(roundi(v.angle() / (TAU / 8.0)), 0, 8)
return DIRS_8[i]
func dir_4(v: Vector2) -> String:
var i := wrapi(roundi(v.angle() / (TAU / 4.0)), 0, 4)
return DIRS_4[i]
func update_animation(v: Vector2) -> void:
if v.length() > 1.0:
facing = dir_8(v)
sprite.play("walk_" + facing)
else:
sprite.play("idle_" + facing)
Keeping facing between frames matters: when the player stops, the idle animation should face the way they were walking, not snap back to south.
If your sheet only has east-facing frames for the side directions, map w, nw and sw to their east counterparts and set flip_h = true. Only do this if the character is symmetrical. A sword in the right hand becomes a sword in the left hand when flipped, and players notice.
Tip Gamepad sticks rarely rest at exactly zero. Input.get_vector() applies the action deadzone for you, which is why the code above uses it rather than reading the axes directly.
Alternative: Sprite2D + AnimationPlayer
AnimatedSprite2D only animates frames. If the same animation also needs to move a hitbox, play a sound, change modulate for a damage flash, or shift a weapon sprite, use Sprite2D with an AnimationPlayer so everything lives on one timeline.
- Add a Sprite2D, assign the sheet to Texture.
- Under Animation, set Hframes to the column count and Vframes to the row count. The sprite now shows a single cell.
- Add an AnimationPlayer as a sibling. Create an animation, set its length (8 frames at 10 FPS = 0.8 s) and turn on looping.
- Select the Sprite2D and click the key icon next to Frame (or Frame Coords for grid sheets) to add a property track. Key frame 0 at 0.0 s, frame 1 at 0.1 s, and so on.
- Set the track’s update mode to Discrete. With the default continuous mode, Godot interpolates between integer frame values and you get skipped or repeated frames.
@onready var anim: AnimationPlayer = $AnimationPlayer
func _physics_process(_delta: float) -> void:
anim.play("walk" if velocity.length() > 1.0 else "idle")
The trade-off: AnimationPlayer setup is slower to author by hand. For a character with 20 animations across 8 directions, nobody wants to key 1,000 frames by clicking. Generate the tracks in a tool script, or stick with SpriteFrames for the frames and use AnimationPlayer only for the extras.
Building SpriteFrames from code
When a sheet follows a fixed layout (one row per direction, N frames per row), building the resource in code is faster and less error-prone than clicking. This function slices a grid sheet into named animations:
func frames_from_sheet(
sheet: Texture2D,
frame_size: Vector2i,
rows: Array[String],
frames_per_row: int,
fps := 10.0,
loop := true) -> SpriteFrames:
var sf := SpriteFrames.new()
sf.remove_animation("default")
for row in rows.size():
var anim_name := rows[row]
sf.add_animation(anim_name)
sf.set_animation_speed(anim_name, fps)
sf.set_animation_loop(anim_name, loop)
for col in frames_per_row:
var atlas := AtlasTexture.new()
atlas.atlas = sheet
atlas.region = Rect2(col * frame_size.x, row * frame_size.y, frame_size.x, frame_size.y)
atlas.filter_clip = true
sf.add_frame(anim_name, atlas)
return sf
Use it like this:
func _ready() -> void:
var sheet := load("res://characters/knight/knight_walk.png") as Texture2D
var dirs: Array[String] = ["walk_s", "walk_w", "walk_e", "walk_n"]
sprite.sprite_frames = frames_from_sheet(sheet, Vector2i(48, 48), dirs, 8, 10.0)
sprite.play("walk_s")
filter_clip = true on each AtlasTexture stops sampling from outside the region, which prevents a one-pixel line of the neighbouring frame showing at the edge.
To keep the result as an asset instead of rebuilding it at runtime, run the code from an @tool script or an EditorScript (File → Run in the script editor) and save it with ResourceSaver.save(sf, "res://characters/knight/knight.tres").
Loading a ready-made .tres SpriteFrames
A .tres SpriteFrames file is a text resource that lists each animation, its speed and loop flag, and an AtlasTexture region for every frame. It points at the sheet PNG through an ext_resource path. AI Sprite Maker’s Godot export gives you exactly this: the PNG sheet plus a .tres with animations already named and timed. The Godot docs page describes the file layout.
To use one:
- Copy the PNG and the
.tresinto your project together, keeping them in the same folder. - If the
ext_resourcepath in the.tresdoesn’t match where you put the PNG, open the.tresin a text editor and fix thepath="res://..."line. Or move the files inside Godot’s FileSystem dock, which updates references for you. - Drag the
.tresonto the Sprite Frames property of anAnimatedSprite2D, or load it in code:
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D
func _ready() -> void:
sprite.sprite_frames = load("res://characters/knight/knight.tres")
print(sprite.sprite_frames.get_animation_names())
sprite.play(sprite.sprite_frames.get_animation_names()[0])
Printing get_animation_names() once is the quickest way to learn what an imported file calls its animations, so you don’t guess strings.
Fixing the common problems
The sprite is blurry
In order of likelihood:
- Default Texture Filter isn’t set to Nearest, or a parent node overrides
texture_filterwith Linear. Check the node’s CanvasItem → Texture → Filter. Inherit is fine if the project default is Nearest. - The sprite sits at a non-integer scale (e.g. 2.5×). Use whole-number scales, and use
integerstretch scale mode. - The
Camera2Dhas a fractional zoom. Use 1, 2, 3 and so on. - The texture was imported with mipmaps or VRAM compression. Fix it in the Import dock and reimport.
Lines or slivers of the next frame at the edges (bleeding)
This shows up as a flickering one-pixel line at the top or side of the sprite, usually only at some positions or zoom levels.
- Turn on the snap settings from the setup section.
- Turn off mipmaps.
- Set
filter_clip = trueon AtlasTextures if you build frames in code. - If it still happens, the sheet needs padding: 1–2 px of transparent space between frames, or extrusion, where each frame’s edge pixels are repeated outward. Re-export the sheet with padding. Aseprite has Border/Shape Padding and Extrude in its export dialog. You can also split the sheet with the free Sprite Sheet Slicer and repack it with the Sprite Sheet Maker. If the new sheet has gaps between frames, enter that gap as Separation in the “Add frames from a Sprite Sheet” dialog.
Frames play in the wrong order
The sheet slicer adds frames in the order you click them. If you used Select All, it goes left to right, top to bottom, so a sheet laid out column by column plays out of order. Clear the animation and click frames in the correct order, or build the SpriteFrames in code where the loop order is explicit.
Also check that the column and row counts are right. Entering 8 columns for a 6-frame strip silently cuts each frame in the wrong place, and you get half-characters sliding across the cell.
The character jitters even though each frame looks fine
That’s a sheet problem, not a Godot problem. If the character’s position inside each frame cell shifts by a pixel or two between frames, it will jitter in game. The feet need to stay on the same baseline and the body centred on the same pivot in every frame. Fix it in the art, or re-center the frames with the Sprite Pivot Editor. Our walk cycle guide covers why it happens.
The origin is in the wrong place
AnimatedSprite2D and Sprite2D are centred by default. For a character, you usually want the origin at the feet so it lines up with the collision shape and Y-sorting works. Either turn off Centered and set Offset to Vector2(-frame_width / 2, -frame_height), or keep it centred and move the sprite node up by half the frame height inside the character scene. Then turn on Y Sort Enabled on the parent so characters lower on screen draw in front.
A setup checklist
Once per project:
- Default Texture Filter: Nearest
- Snap 2D Transforms to Pixel and Snap 2D Vertices to Pixel: on
- Stretch Mode viewport (or canvas_items), Aspect keep, Scale Mode integer
For each sheet:
- Import: Lossless, no mipmaps, Fix Alpha Border on
- Know the frame size, columns, rows and padding before slicing
- One animation per motion and direction, FPS set, loop only where it should loop
- Origin at the feet, Y-sort on the parent
If you’d rather skip the slicing step entirely, the Godot export from our 8-direction sprite generator and sprite animation tool produces the .tres with all the directions already split into named animations. For sheets from anywhere else, the code in Building SpriteFrames from code handles any grid layout in a few lines.
For how other engines read the same sheet, see Sprite sheets in Unity and Phaser 3 sprite sheet animation.