Navigate and inspect Animation Blueprints, state machines, states, and transitions
π§ Brains complement: IF an
unreal-engine-skills-managertool (external MCP) exists in this session, call it with{action: "load", skill: "animation-system"}for UE domain knowledge on this topic β correct APIs, architecture, best practices β and treat it as the rubric for any review / "best practices" question. If no such tool is available (e.g. running under Claude Code or Codex without that MCP), skip this line entirely and proceed with this skill alone β do NOT attempt the call.
Loading skills: skills load through the engine's
AgentSkillToolset(ListSkills/GetSkills) β there is novibeue-skills-managertool. Run VibeUE services withexecute_python_code(unreal.AnimGraphService.<method>()); reach engine toolsets withcall_tool.
An AnimBP is a Blueprint. The state-machine/AnimGraph authoring here is VibeUE
AnimGraphService(intact). For its variables and compile, note VibeUE'sBlueprintServicewas trimmed: variable creation and blueprint compile moved to the engine toolseteditor_toolset.toolsets.blueprint.BlueprintTools(add_variable,compile_blueprint), called viacall_tool.BlueprintServicestill haslist_variables,get_variable_info,set_variable_default_value,variable_exists, components, and overridable functions. Bool fields on info structs drop the C++bprefix in Python β useis_pure,is_output,already_overridden(NOTb_is_pure); calldiscover_python_classto confirm field names rather than guessing.
add_transition() only draws the arrow between two states. The transition's rule graph
starts empty, which evaluates to false, so the transition is inert β the state
machine will never move. You MUST set a rule on every transition:
import unreal
abp = "/Game/ABP_Character"
BT = "editor_toolset.toolsets.blueprint.BlueprintTools"
# 1) Add the bool/float variable the rule reads, then COMPILE so it resolves.
# Variable creation + compile are ENGINE BlueprintTools (call_tool), not VibeUE:
call_tool(BT, "add_variable", {"blueprint": {"refPath": abp}, "variable_name": "bIsDead", "variable_type": "bool"})
call_tool(BT, "compile_blueprint", {"blueprint": {"refPath": abp}})
# (Run describe_toolset(BT) for exact arg names.)
# 2) Draw the transition (VibeUE AnimGraphService)
unreal.AnimGraphService.add_transition(abp, "Locomotion", "Idle", "Dead", 0.2)
# 3) Give it a rule β THIS is what makes it fire
unreal.AnimGraphService.set_transition_rule_from_bool(abp, "Locomotion", "Idle", "Dead", "bIsDead")
Rule options:
set_transition_rule_from_bool(abp, machine, src, dst, bool_var, invert=False)set_transition_rule_comparison(abp, machine, src, dst, float_var, op, value) β op β greater/less/greater_equal/less_equal/equal/not_equalset_transition_rule_automatic(abp, machine, src, dst, trigger_time=-1.0) β fires when the source state's animation is (almost) finished; perfect for one-shots (attackβidle)clear_transition_rule(...) β non-destructively reset a ruleAfter authoring, always run validate_state_machine() (see below) and compile.
Use AnimGraphService to navigate directly to states and graphs:
import unreal
# Open an AnimBP's main AnimGraph
unreal.AnimGraphService.open_anim_graph("/Game/ABP_Character", "AnimGraph")
# Open a specific state inside a state machine
unreal.AnimGraphService.open_anim_state("/Game/ABP_Character", "Locomotion", "IdleLoop")
# Open a transition rule
unreal.AnimGraphService.open_transition("/Game/ABP_Character", "Locomotion", "Idle", "Walk")
State machine names come from the node title, not the graph name. Use list_state_machines() to get the correct names:
machines = unreal.AnimGraphService.list_state_machines("/Game/ABP_Character")
for m in machines:
print(f"Machine: {m.machine_name}") # Use THIS name for open_anim_state
All name lookups are case-insensitive, but prefer using exact names from introspection:
# Both work:
unreal.AnimGraphService.open_anim_state(path, "State Controller", "In Air Loop")
unreal.AnimGraphService.open_anim_state(path, "state controller", "in air loop")
CRITICAL: AnimBlueprints require a skeleton reference. Always find the skeleton first.
import unreal
# Step 1: Find skeleton from reference blueprint
ref_bp_path = "/Game/Blueprints/SandboxCharacter_CMC_ABP"
if unreal.EditorAssetLibrary.does_asset_exist(ref_bp_path):
ref_bp = unreal.load_asset(ref_bp_path)
skeleton = ref_bp.get_editor_property('target_skeleton')
skeleton_path = skeleton.get_path_name()
print(f"Using skeleton: {skeleton_path}")
else:
# Or load directly if you know the path
skeleton_path = "/Game/Characters/UEFN_Mannequin/Meshes/SK_UEFN_Mannequin"
skeleton = unreal.load_asset(skeleton_path)
# Step 2: Delete existing if needed
asset_path = "/Game/Tests/ABP_TestCharacter"
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
# Step 3: Create AnimBlueprint with skeleton
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
blueprint_factory = unreal.AnimBlueprintFactory()
blueprint_factory.set_editor_property("target_skeleton", skeleton)
blueprint_factory.set_editor_property("parent_class", unreal.AnimInstance)
animation_blueprint = asset_tools.create_asset(
"ABP_TestCharacter",
"/Game/Tests",
unreal.AnimBlueprint,
blueprint_factory
)
# Step 4: Save
if animation_blueprint:
unreal.EditorAssetLibrary.save_asset(asset_path, only_if_is_dirty=False)
print(f"Created: {asset_path}")
Opening in Editor:
# Use open_editor_for_assets (plural) with list
editor_subsystem = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
opened = editor_subsystem.open_editor_for_assets([animation_blueprint])
β οΈ DO NOT use AssetEditorSubsystem.open_editor_for_asset() (singular) - it will cause AttributeError.
import unreal
abp_path = "/Game/Blueprints/SandboxCharacter_CMC_ABP"
# Get overview
parent = unreal.AnimGraphService.get_parent_class(abp_path)
skeleton = unreal.AnimGraphService.get_skeleton(abp_path)
print(f"Parent: {parent}, Skeleton: {skeleton}")
# List all graphs
graphs = unreal.AnimGraphService.list_graphs(abp_path)
for g in graphs:
print(f"Graph: {g.graph_name} ({g.graph_type}), Nodes: {g.node_count}")
import unreal
abp_path = "/Game/ABP_Character"
# Find all state machines
machines = unreal.AnimGraphService.list_state_machines(abp_path)
for machine in machines:
print(f"\nState Machine: {machine.machine_name} ({machine.state_count} states)")
# List states in this machine
states = unreal.AnimGraphService.list_states_in_machine(abp_path, machine.machine_name)
for state in states:
end_marker = " [END]" if state.is_end_state else ""
print(f" - {state.state_name} ({state.state_type}){end_marker}")
import unreal
abp_path = "/Game/Blueprints/SandboxCharacter_CMC_ABP"
# Open the AnimBP and navigate to a specific state
unreal.AnimGraphService.open_anim_state(
abp_path,
"State Controller", # State machine name
"In Air Loop" # State name
)
import unreal
abp_path = "/Game/ABP_Character"
machine_name = "Locomotion"
# Get all transitions in a state machine
transitions = unreal.AnimGraphService.get_state_transitions(abp_path, machine_name)
for t in transitions:
auto = " [AUTO]" if t.is_automatic else ""
print(f"{t.source_state} -> {t.dest_state} (blend: {t.blend_duration}s){auto}")
# Get transitions for a specific state
idle_transitions = unreal.AnimGraphService.get_state_transitions(abp_path, machine_name, "Idle")
build_state_machineBuild (or extend) a whole machine from one JSON spec in a single atomic, idempotent call. Re-running it never duplicates existing states/transitions. It sets animations, rules, the entry state, then compiles β and returns a JSON report.
import unreal, json
abp = "/Game/ABP_Character"
BT = "editor_toolset.toolsets.blueprint.BlueprintTools"
# Add the variables the rules read FIRST, then compile so the rules can resolve them.
# Variable creation + compile are ENGINE BlueprintTools (call_tool), not VibeUE:
call_tool(BT, "add_variable", {"blueprint": {"refPath": abp}, "variable_name": "Speed", "variable_type": "float"})
call_tool(BT, "add_variable", {"blueprint": {"refPath": abp}, "variable_name": "bAttack", "variable_type": "bool"})
call_tool(BT, "compile_blueprint", {"blueprint": {"refPath": abp}})
spec = {
"states": [
{"name": "Idle", "animation": "/Game/Anims/Idle", "loop": True, "pos": [0, 0]},
{"name": "Walk", "animation": "/Game/Anims/Walk", "loop": True, "pos": [350, 0]},
{"name": "Attack", "animation": "/Game/Anims/Attack", "loop": False, "pos": [350, 250]},
],
"transitions": [
{"from": "Idle", "to": "Walk", "rule": {"type": "comparison", "variable": "Speed", "op": "greater", "value": 10}, "blend": 0.15},
{"from": "Walk", "to": "Idle", "rule": {"type": "comparison", "variable": "Speed", "op": "less_equal", "value": 10}},
{"from": "Idle", "to": "Attack", "rule": {"type": "bool", "variable": "bAttack"}},
{"from": "Attack", "to": "Idle", "rule": {"type": "automatic"}}, # when the attack anim ends
],
"entry": "Idle",
}
report = json.loads(unreal.AnimGraphService.build_state_machine(abp, "Locomotion", json.dumps(spec)))
print(report) # {"success":true,"states_created":3,"transitions_created":4,"errors":[]}
rule.type β bool (variable, optional invert), comparison (variable,op,value),
automatic (optional trigger_time), always (always-true), or omit rule to leave it inert.
import unreal
abp = "/Game/ABP_Character"
# State machine + states
unreal.AnimGraphService.add_state_machine(abp, "Locomotion", 0, 0)
unreal.AnimGraphService.add_state(abp, "Locomotion", "Idle", 0, 0)
unreal.AnimGraphService.add_state(abp, "Locomotion", "Walk", 350, 0)
# One call: create+assign the sequence player inside the state AND wire it to Output Pose
unreal.AnimGraphService.set_state_animation(abp, "Locomotion", "Idle", "/Game/Anims/Idle", True)
unreal.AnimGraphService.set_state_animation(abp, "Locomotion", "Walk", "/Game/Anims/Walk", True)
# Default/entry state
unreal.AnimGraphService.set_entry_state(abp, "Locomotion", "Idle")
# Transitions + rules (rules are mandatory β see top gotcha)
unreal.AnimGraphService.add_transition(abp, "Locomotion", "Idle", "Walk", 0.15)
unreal.AnimGraphService.set_transition_rule_comparison(abp, "Locomotion", "Idle", "Walk", "Speed", "greater", 10.0)
unreal.AnimGraphService.set_transition_priority(abp, "Locomotion", "Idle", "Walk", 1)
# Compile via engine BlueprintTools:
call_tool("editor_toolset.toolsets.blueprint.BlueprintTools", "compile_blueprint", {"blueprint": {"refPath": abp}})
validate_state_machineresult = unreal.AnimGraphService.validate_state_machine(abp, "Locomotion")
print(f"valid={result.is_valid} states={result.state_count} transitions={result.transition_count}")
for e in result.errors: print("ERROR:", e) # inert transitions, no entry state
for w in result.warnings: print("WARN :", w) # unreachable states, states with no animation
Treat any errors as a build failure. The most common error is an inert transition
(a transition with no rule) β fix it with one of the set_transition_rule_* calls.
import unreal
abp_path = "/Game/ABP_Character"
sequences = unreal.AnimGraphService.get_used_anim_sequences(abp_path)
for seq in sequences:
print(f"{seq.sequence_name}")
print(f" Path: {seq.sequence_path}")
print(f" Used in: {seq.used_in_graph}")
import unreal
abp_path = "/Game/ABP_Character"
# If you have a node GUID from get_nodes_in_graph
node_id = "ABC123-DEF456-..."
unreal.AnimGraphService.focus_node(abp_path, node_id)
Python Naming Convention: C++ types like
FAnimStateMachineInfoare exposed asAnimStateMachineInfoin Python (noFprefix).
| Property | Type | Description |
|---|---|---|
machine_name |
string | Display name of the state machine |
node_id |
string | Node GUID |
state_count |
int | Number of states |
parent_graph_name |
string | Graph containing this machine |
| Property | Type | Description |
|---|---|---|
state_name |
string | Display name of the state |
node_id |
string | Node GUID |
state_type |
string | "State", "Conduit", "Entry" |
is_end_state |
bool | True if no outgoing transitions |
pos_x |
float | X position in graph |
pos_y |
float | Y position in graph |
| Property | Type | Description |
|---|---|---|
transition_name |
string | Display name |
node_id |
string | Node GUID |
source_state |
string | Source state name |
dest_state |
string | Destination state name |
priority |
int | Priority (lower = higher) |
blend_duration |
float | Crossfade time in seconds |
is_automatic |
bool | Auto-transition based on sequence |
rule_type |
string | None (inert), Bool, Comparison, Automatic, Custom |
rule_variable |
string | Bound variable name (for Bool/Comparison rules) |
rule_summary |
string | Human-readable rule, e.g. Speed > 150, bIsDead == true |
has_rule |
bool | False = inert (never fires). True = transition can fire |
Returned by validate_state_machine().
| Property | Type | Description |
|---|---|---|
is_valid |
bool | True when there are no blocking errors |
state_count |
int | Number of states |
transition_count |
int | Number of transitions |
errors |
[string] | Blocking problems (inert transitions, no entry state) |
warnings |
[string] | Non-blocking issues (unreachable states, states with no animation) |
Not every AnimBP is a state machine. A spinning rotor, a windmill sail, a turret yaw, a rudder that follows a variable β all are a pose piped through Modify Bone nodes, which is a different graph shape and has its own traps.
The chain is always: a pose source β Local To Component β one Modify Bone per bone β
Component To Local β Output. Skeletal controls only operate in component space, and
connect_anim_nodes does not insert the space conversions for you.
import unreal
AG, BS = unreal.AnimGraphService, unreal.BlueprintService
PATH, G = "/Game/ABP_Windmill", "AnimGraph"
REF = "SPAWN AnimGraphNode_LocalRefPose|Local Space Ref Pose" # no sequence needed
L2C = "SPAWN AnimGraphNode_LocalToComponentSpace|Local To Component"
C2L = "SPAWN AnimGraphNode_ComponentToLocalSpace|Component To Local"
ref = BS.create_node_by_key(PATH, G, REF, -900, -80)
l2c = BS.create_node_by_key(PATH, G, L2C, -640, -80)
AG.connect_anim_nodes(PATH, G, ref, "Pose", l2c, "LocalPose")
mb = AG.add_modify_bone_node(PATH, G, "sails", -340, -80)
AG.connect_anim_nodes(PATH, G, l2c, "ComponentPose", mb, "ComponentPose")
getter = BS.create_node_by_key(PATH, G, "SPAWN K2Node_VariableGet|Get SailRotation", -400, 200)
BS.connect_nodes(PATH, G, getter, "SailRotation", mb, "Rotation")
c2l = BS.create_node_by_key(PATH, G, C2L, 0, -80)
AG.connect_anim_nodes(PATH, G, mb, "Pose", c2l, "ComponentPose")
AG.connect_to_output_pose(PATH, G, c2l, "Pose")
Pin names to remember: LocalPose in / ComponentPose out on Local To Component; ComponentPose
in / Pose out on Modify Bone and Component To Local.
connect_anim_nodes
(and connect_to_output_pose) to an already-wired single-link input now breaks the existing link
and wires the new source in its place β no need to disconnect_anim_node(node, "Pose") first β and
returns False (logging a Warning with the schema's reason) when the schema refuses. Previously it
stacked an illegal second link that compiled the AnimBP to BS_ERROR with an empty error list.add_modify_bone_node creates the node with rotation_mode, translation_mode and scale_mode all
set to Ignore. You can connect the Rotation pin, compile clean, and see absolutely nothing move β
there is no warning. Set the mode on the node's inner node struct after creating it:
for i in range(0, 12):
o = unreal.find_object(None, f"{PATH}.{name}:{G}.AnimGraphNode_ModifyBone_{i}")
if not o:
continue
n = o.get_editor_property("node")
n.set_editor_property("rotation_mode", unreal.BoneModificationMode.BMM_ADDITIVE)
n.set_editor_property("rotation_space", unreal.BoneControlSpace.BCS_COMPONENT_SPACE)
n.set_editor_property("translation_mode", unreal.BoneModificationMode.BMM_IGNORE)
n.set_editor_property("scale_mode", unreal.BoneModificationMode.BMM_IGNORE)
o.set_editor_property("node", n) # write the struct back, or the change is lost
BMM_ADDITIVE and BMM_REPLACE behave identically when the bone is at identity in the ref pose.
create_node_by_key needs the spawner key, which embeds the node's display name, and the two
kinds of variable are spelled differently:
| Variable lives on | Display name | Spawner key |
|---|---|---|
The AnimBP itself (add_member_variable) |
Get SailRotation |
SPAWN K2Node_VariableGet|Get SailRotation |
A C++ UAnimInstance subclass |
Get Sail Rotation (spaced) |
SPAWN K2Node_VariableGet|Get Sail Rotation |
Never hard-code either β discover_nodes(PATH, "SailRotation", "", 20) and match on display_name,
which works for both.
Put the accumulator in the AnimBP's EventGraph on Event Blueprint Update Animation (its
DeltaTimeX pin), write a float, and convert it to the Rotator the Modify Bone reads:
DeltaTimeX ββ
ββ float * float β float * float ββ (RPM β deg/sec is Γ 6)
SpinRPM βββββ 6.0 ββββββββββ€
ββ float + float ββ SET SailAngle
SailAngle (get) βββββββββββββββββββββββββββββββ
SailAngle (get) ββ MakeRotator(Roll=) ββ SET SailRotation
Float maths nodes are FUNC KismetMathLibrary::Multiply_DoubleDouble and ::Add_DoubleDouble
(display names float * float / float + float) β Blueprint "float" is a double in UE5.
MakeRotator is FUNC KismetMathLibrary::MakeRotator with pins Roll, Pitch, Yaw.
Verify it is actually running rather than trusting the compile: in PIE read the variable off the live
instance, twice, in separate execute_python_code calls (see the pie-testing skill β Python
blocks the game thread, so a sampling loop inside one call returns the same value every time).
comp = actor.get_components_by_class(unreal.SkeletalMeshComponent)[0]
ai = comp.get_anim_instance()
print(ai.get_editor_property("SailAngle"), comp.get_socket_rotation("sails").roll)
import unreal
if unreal.AnimGraphService.is_anim_blueprint("/Game/SomeAsset"):
# It's an AnimBP, safe to use AnimGraphService
machines = unreal.AnimGraphService.list_state_machines("/Game/SomeAsset")
import unreal
abp_path = "/Game/ABP_Character"
machines = unreal.AnimGraphService.list_state_machines(abp_path)
for machine in machines:
states = unreal.AnimGraphService.list_states_in_machine(abp_path, machine.machine_name)
for state in states:
print(f"{machine.machine_name}/{state.state_name}")
execute_python_code)scripts/build_state_machine.txt β add a state machine with two states, set state animations, add a transition, compile.connect_anim_nodes replaces an existing pose link on the target input rather than erroring; it returns False if the schema refuses the connection, so check the return instead of hunting for a BS_ERROR with an empty error list.