This page explains how to control SpriteStudioPlayer2D using Godot's GDScript.
The intuitive API aligns with Godot's design philosophy (nodes and signals), making it very easy to integrate into your game logic.
Just like operating from the Inspector, you can control animations using simple method calls from your scripts.
extends Node2D
@onready var ss_player = $SpriteStudioPlayer2D
func _ready():
# Specify the animation name
ss_player.set_animation("attack")
# Start playback
ss_player.play()
func _process(delta):
# Pause / resume with the Space key
if Input.is_action_just_pressed("ui_accept"):
if ss_player.is_pausing():
ss_player.resume()
elif ss_player.is_playing():
ss_player.pause()
else:
# Stopped — play() starts over from the section start.
ss_player.play()Important
There are three states, not two. is_playing() stays true while paused — a pause is a hold, not a stop — so is_pausing() is what tells a held animation from a running one, and both are false before the first play() and after stop().
play() is not a resume. It rewinds to the start of the section and re-arms the loop counter. Lift a hold with resume(), and carry on from a stop with play(get_frame_no()).
One of the most powerful features for Godot users is event linkage using "Signals".
SpriteStudioPlayer2D emits useful signals when its playback state changes or when user data is triggered.
animation_changed(anim_name): Emitted when the animation is changed.animation_started(anim_name): Emitted when animation playback starts.animation_finished(anim_name): Emitted once every configured loop has been played (never under an infinite loop).animation_looped(anim_name): Emitted when the animation loops and returns to the beginning.user_data(payload): Emitted when reaching a frame containing user data (events) configured in the animation.signal_emitted(command, value, info): Emitted when reaching a "Signal" key on the timeline.audio(payload): Emitted when reaching an audio key. Observation only — the player sounds it as well unless you turn that off. See Audio Playback.frame_updated(frame_no): Emitted right after the frame's part poses are finalized. See Part Tracking.
All three timeline events (user_data, signal_emitted, audio) carry the same origin fields — part_index, part_name and frame_no — so a handler can tell which part fired without keeping a table of its own. On user_data and audio they sit in the payload; on signal_emitted they arrive as the separate info argument, because value's keys are the parameter ids the animator authored and a fixed key could shadow one.
Here is an example where an "idle" animation automatically plays after an "attack" animation finishes.
func _ready():
# You can also connect via the editor UI (Node tab), but to do it via code:
ss_player.animation_finished.connect(_on_animation_finished)
func _on_animation_finished(anim_name: String):
if anim_name == "attack":
# Return to idle state after the attack
ss_player.set_animation("idle")
ss_player.play()This is an example of receiving user data configured in SpriteStudio (such as playing footsteps or generating attack hitboxes) and processing it in the game.
func _ready():
# Connect the user data signal
ss_player.user_data.connect(_on_user_data)
func _on_user_data(payload):
# payload is a Dictionary; only the keys that were set are present (string / integer / point / rect)
# Example: Check the string set as user data and process accordingly
if payload.get("string") == "play_footstep":
$AudioStreamPlayer.play()
elif payload.get("string") == "attack_hit":
# Example of passing the damage amount using an integer value
var damage = payload.get("integer", 0)
spawn_hitbox(damage)"Signal" keys carry a named command plus typed parameters, which suits commands aimed at the game (spawn an effect, open a gate) better than a bare string.
func _ready():
ss_player.signal_emitted.connect(_on_signal_emitted)
func _on_signal_emitted(command: String, value: Dictionary, info: Dictionary):
# `value` is keyed by the parameter ids authored in SpriteStudio.
# `info` says where the key came from: part_index / part_name / frame_no.
if command == "spawn_effect":
var effect_name: String = value.get("name", "")
spawn_effect_at(effect_name, ss_player.get_part_transform(info["part_name"]))When you want to change character equipment in-game, you can dynamically replace the texture of specific parts (cell maps) from your code.
func change_costume():
# Use the cell map name defined in SpriteStudio (retrievable via get_cellmap_names(); shown under CellMap Overrides in the Inspector)
var new_costume_texture = preload("res://assets/sailor_uniform.png")
ss_player.set_cellmap_texture("Clothes 1", new_costume_texture)This feature allows you to build an efficient avatar system without needing to prepare multiple animation variations for each part.
A feature that makes a user-provided node (weapon, effect, hit detection, etc.) follow a specified part every frame. The player neither creates nor frees nodes; it only writes the pose (constraint style), and you own the target's lifecycle.
Tracking is done with the dedicated SpriteStudioPartAttachment2D node. Place it as a child of SpriteStudioPlayer2D and set part_name to the part you want to follow. Anything you hang under that node — a weapon, an effect — follows along through scene-tree inheritance.
The properties follow Godot's own
RemoteTransform2D, plusfollow_path/part_nameto say which player and which part to read.
| Property | Description |
|---|---|
Part Name (part_name) |
The name of the part to follow (the PartData name in the .ssab). The inspector offers a dropdown populated from the asset's part names (still typable, for when the player cannot be resolved). Skinned-mesh parts are left out of the dropdown — see the note below |
Follow Path (follow_path) |
The SpriteStudioPlayer2D to read from. Empty (default) uses the nearest ancestor player |
Remote Path (remote_path) |
The Node2D to drive. Empty (default) drives this node itself, and its children follow through scene-tree inheritance. Set it to push the pose to an external node instead (for assets that live outside the player's subtree) |
Use Global Coordinates (use_global_coordinates) |
ON (default) writes the pose in global coordinates, OFF in the target's local coordinates |
Update Position / Update Rotation (update_position / update_rotation) |
Reflect position / rotation (both ON by default) |
Update Scale (update_scale) |
Reflect scale (OFF by default) |
On Part Hidden (on_part_hidden) |
Behavior on frames where the part is hidden. Follow Always (keep following; default) / Hide Target (hide the target). From a script: SpriteStudioPartAttachment2D.FOLLOW_ALWAYS / .HIDE_TARGET |
Mesh (skinned) parts cannot be followed directly. A bone-bound mesh part (a deforming part such as a hand or a face) has its vertices drawn by bone skinning, while the part's own transform stays at its setup node position — usually near the character root. So targeting such a part makes the follower snap near the root rather than onto the visible art. To follow the position of a mesh part, add a NULL part (a bone-point / empty part) at that spot in SpriteStudio and target that part's name instead; a bone (armature) or joint part works the same way.
To steer away from the mistake, the
part_namedropdown omits skinned-mesh parts — you can only reach one by typing its name in the field by hand (kept possible for the rare deliberate case). Wheneverpart_namedoes resolve to a skinned mesh, the node shows a configuration warning (the ⚠ next to it in the Scene dock) so the choice is never silent;is_part_skinned_mesh(part_name)reports the same thing from a script. Rigid (deform-only) meshes track their node faithfully, so they stay in the dropdown and raise no warning.
Instead of placing a node, you can also ask the player for a part's pose directly.
@onready var ss_player = $SpriteStudioPlayer2D
@onready var muzzle = $Muzzle
func _ready():
print(ss_player.get_part_names()) # -> ["root", "body", "hand_R", ...]
# Emitted every time the frame's part poses are finalized
ss_player.frame_updated.connect(_on_frame_updated)
func _on_frame_updated(frame_no: float):
# get_part_transform() is player-local; multiply by the player's transform for global
muzzle.global_transform = ss_player.global_transform * ss_player.get_part_transform("hand_R")| API | Description |
|---|---|
get_part_names() |
Every part name in the asset (.ssab) |
find_part_index(part_name) |
Part index, or -1 if the part is not in the asset |
get_part_transform(part_name) |
The part's transform for the current frame (a Transform2D, player-local, with flip_h / flip_v / offset already applied). Identity if the part is unknown |
is_part_hidden(part_name) |
Whether the part is hidden on the current frame. false if the part is unknown |
is_part_skinned_mesh(part_name) |
Whether the part is a bone-skinned mesh — a poor follow target (see the note above). false if the part is unknown or is a rigid (deform-only) mesh |
signal frame_updated(frame_no: float) |
Emitted right after the frame's part poses are finalized |
When you only need the pose at a single moment (a projectile spawn point, for example) rather than continuous following, calling
get_part_transform()directly is simpler than placing aSpriteStudioPartAttachment2D.
Tracking is driven by the frame_updated signal the player emits right after finishing its own update. That is after the part transforms are finalized and before the render phase, so the target updates within the same frame. Which process it fires in follows the player's animation_process_mode (Idle (default) / Physics).
Godot's Transform2D holds a full 2x3 affine transform, so when update_position / update_rotation / update_scale are all ON the transform is assigned whole. That matches the part's position, rotation and scale exactly, including a negative scale (mirror), whether the target sits under the player or in a separate hierarchy. A part's skew is not part of the followed pose: SpriteStudio treats skew as a per-part vertex deformation that does not inherit to children, so — like a child part — an attachment never receives it.
Turning any of them OFF writes only the enabled components individually, like RemoteTransform2D. Only update_scale is OFF by default, so position and rotation alone are reflected out of the box.
Turn all three ON when you use
flip_h/flip_v. A flipped part's pose is a mirror, and a mirror can only be expressed as a negative scale. Withupdate_scaleOFF (the default) the mirror never reaches the target, andflip_h(horizontal) additionally leaves a 180-degree difference in orientation (flip_vdoes not). The two differ becauseTransform2Ddecomposes a mirror into a rotation plus a negative scale with the sign placed on the Y axis: a horizontal mirror needs an extra 180-degree rotation to fit that form, and it is the rotation that keeps it.flip_h/flip_vare not the only source — a part or one of its parents carrying a negative scale in SpriteStudio mirrors the pose the same way.
get_part_transform()itself stays exact when flipped.Transform2Dcan hold the mirror as-is, soget_rotation()andget_scale()match the part exactly as a pair (the Y component ofget_scale()goes negative when mirrored). Reading only one of them drifts, for the reason above.
A target in a separate hierarchy can lag by one frame. The pose is written using the player's
global_transformas sampled at drive time, so if you move the player afterwards, the target does not follow until the next frame. ASpriteStudioPartAttachment2D(and its children) placed under the player always follows, through hierarchy inheritance.
Do not track with a
RigidBody2D. Overwriting its transform every frame reads as a teleport to the solver and breaks the physics. If you need to push other bodies — a moving platform, say — target Godot'sAnimatableBody2D(withsync_to_physicsON) and set the player'sanimation_process_modetoPhysicsso tracking is driven on the physics frame. To merely carry a hit box,Area2D/StaticBody2Dis enough.
- The attachment controls the target's
visible. It is hidden automatically in the two cases below, and shown again automatically once the condition clears, so a visibility state you set yourself may be overwritten.- The part name does not exist in the asset (always hidden, regardless of the
On Part Hiddensetting) - The part is hidden on this frame and
On Part HiddenisHide Target
- The part name does not exist in the asset (always hidden, regardless of the
- Part names resolve against the parts of the
.ssabthe player itself has loaded. Parts inside an Instance part (the child animation) cannot be specified (the Instance part itself can). - Part names resolve per asset (
.ssab), independent of the animation. Swapping the.ssabre-resolves them automatically, so nothing has to be set up again. - If several parts share a name, the first one found is used.
- Only the spatial transform is tracked. Draw order (Z order) is not, so a tracked node is never slotted automatically between SpriteStudio parts. Use
z_indexor similar when you need a specific ordering. - Targets must be
Node2D-based nodes.Control(UI) is laid out by anchors and rects and cannot be targeted. - In the editor, tracking is applied as well whenever the player updates — during preview playback or while scrubbing frames.
Per-part runtime overrides let a script say "make this part this color / this cell / hidden now". An override wins over both the keyframe and any animation blending, so it does not have to fight the animation.
@onready var ss_player = $SpriteStudioPlayer2D
func _ready():
# Tint a part red (multiply). Applies to normal (image) parts.
ss_player.set_part_color_override("body", Color.RED, 1) # 1 = Mul
# Or give each of the four corners its own color, for a gradient.
ss_player.set_part_color_override_corners(
"body", PackedColorArray([Color.RED, Color.RED, Color.BLUE, Color.BLUE]))
# Make a part draw a different cell (cell map name is written without ".ssce").
ss_player.set_part_cell_override("body", "Ringo", "effect3")
# Force-hide a part, cascading to its descendants.
ss_player.set_part_visibility_override("body", true, true)
# Revert
ss_player.clear_part_color_override("body")
ss_player.clear_all_part_overrides()| Method | Description |
|---|---|
find_part_index(part_name) |
Part index, or -1 if the part is not in the asset |
set_part_color_override(part_name, color, blend_op = COLOR_BLEND_MIX, priority = OVERRIDE_PRIORITY_HOLD_UNTIL_NEXT_ANIMATION) |
Color override (single color) |
set_part_color_override_corners(part_name, corners, blend_op = COLOR_BLEND_MIX, priority = OVERRIDE_PRIORITY_HOLD_UNTIL_NEXT_ANIMATION) |
Color override with a distinct color per corner (gradient) |
set_part_cell_override(part_name, cellmap_name, cell_name, priority = OVERRIDE_PRIORITY_HOLD_UNTIL_NEXT_ANIMATION) |
Draw a different cell |
set_part_visibility_override(part_name, force_hidden, cascade = false) |
Force-hide (force_hidden = false reverts to the animation) |
clear_part_color_override / clear_part_cell_override / clear_part_visibility_override |
Clear one override on one part |
clear_all_part_overrides() |
Clear every override on the player |
*_by_index(part_index, ...) |
Part-index variant of each method above (skips the name lookup) |
Every method returns false when the part is unknown or the runtime rejects the call. A single color and a four-corner color share one override slot per part, so the last call wins and clear_part_color_override() clears either kind.
The cell map / cell names you can pass to a cell override are enumerated from the player:
print(ss_player.get_cellmap_names()) # -> ["Ringo", ...]
print(ss_player.get_cell_names("Ringo")) # -> ["effect3", ...]Both read the bound SSABResource and return an empty array when none is assigned. The same two methods are also available on the resource itself (ss_player.get_ssab_resource().get_cellmap_names()), which is the way to enumerate an .ssab you have not put on a player yet.
On choosing between a texture swap and a cell override:
set_cellmap_texture()in the previous section replaces a whole cell map (texture) at once, affecting every part that uses it. This feature instead replaces the cell that a single part draws. Pick whichever matches your intent.
On using part indices: Part indices are stable within one asset (the same
.ssab), so if you set overrides frequently, resolve the name once withfind_part_index()and reuse that index with the*_by_index()variants.
The blend_op of set_part_color_override() offers the same four operations as the keyframed Part Color.
| Constant | Value | Blend operation |
|---|---|---|
COLOR_BLEND_MIX |
0 |
Mix (default) |
COLOR_BLEND_MUL |
1 |
Mul (multiply) |
COLOR_BLEND_ADD |
2 |
Add |
COLOR_BLEND_SUB |
3 |
Sub (subtract) |
An out-of-range value fails the call and returns false.
Color and cell overrides conflict with the animation, so they take a priority (visibility does not — it is a plain force-hide flag, and any new animation clears it):
| Constant | Value | Behavior |
|---|---|---|
OVERRIDE_PRIORITY_OVERWRITE_ON_NEXT_KEYFRAME |
0 |
The override applies until the animation data updates that attribute |
OVERRIDE_PRIORITY_HOLD_UNTIL_NEXT_ANIMATION |
1 |
The override wins for the current animation and is cleared when a new animation is set up (default) |
OVERRIDE_PRIORITY_PERMANENT |
2 |
The override applies for as long as the same animation data (.ssab) is playing, surviving animation changes |
- An override lands on the player's next tick, not on the call: it changes what the current frame draws without changing which frame it is, so a read-back (
is_part_hidden(), for instance) before that tick reports the frame computed before it. The tick does not have to advance the animation — a held frame, a paused or stopped player, andANIMATION_PROCESS_MANUALall pick it up. - Color applies to normal parts, cell to normal and mask parts; other part types silently ignore the override (the call still returns
true). - Colors are interpreted in the same 8-bit sRGB space as the authored Part Color, and alpha is pre-multiplied by the runtime — pass the color as authored, without converting it yourself.
- A cell override is resolved when you set it, so an unknown cell map / cell name fails immediately (returns
false). - Overrides live on the runtime, which owns their lifecycle. Do not re-apply them after an animation change; choose the priority mode that expresses what you want instead.
- Assigning a different
.ssabresource clears every override, because part identity is lost. - Overrides do not reach parts inside an instance part (the child animation runs as a separate player). Force-hiding the instance part itself does stop its contents from being drawn.
On when an override is not reflected in the drawing: While playback is stopped or paused — or on any frame that does not advance — the drawing is not rebuilt, so setting or clearing an override will not appear on screen. Call
set_frame_no(get_frame_no())to force a redraw when you need it reflected immediately.