Skip to main content

Animation: Character State Machine

A blue cube "character" cycles through three behavioural states — Idle, Walk, Run — on each tap. Each state owns its own motion profile (bob rate, sway amplitude, yaw speed, forward lean), and a CharacterStateMachine script picks the active row and drives the character's Transform every frame.

This is the state-machine pattern you'd wire to an Animator component carrying idle / walk / run clips on a rigged 3D character. The demo uses Transform mutation as a proxy because it should run from a fresh empty project — but the script's production-grade replacement (one animator.play("walk", AnimationWrapMode.Repeat, 1.0, 0.2) call per state advance) is documented inline in the script comments and in the "What you learned" section.

Character State Machine demo running in Effect House preview

What you'll build

  • A Character cube at the origin with localScale = (0.4, 0.4, 0.4) wearing a Standard PBR material with mild emissive blue. The cube reads as a clear, low-poly stand-in for an animated character.
  • A RimLight (SpotLight) at (0, 4, -6) reversed — keeps the silhouette readable across all three states.
  • A TitleText and StatusText 2D HUD that mirrors the active state (e.g., 2 of 3 / Walk).
  • A GameController empty SceneObject hosting CharacterStateMachine — the script that subscribes to global EventType.Touch, advances the active state, and drives the character's Transform per frame.

Open the demo

↓ character-state-machine.zip

Unzip and open in Effect House (5.9.0+). The opening scene contains:

  • Camera — slightly downward-pitched perspective camera at (0, 1, 14) with localEulerAngles = (-10, 0, 0), framing the character at chest height.
  • Character — the blue cube whose Transform the script mutates each frame. In a production effect, this is the rigged 3D character whose Animator plays named clips.
  • Directional Light (auto-created) — left enabled.
  • Environment Light — kept on for the global ambient floor.
  • RimLight — back-spot for silhouette pop.
  • TitleText + StatusText — the 2D HUD.
  • GameController — empty SceneObject hosting the CharacterStateMachine.

The Animator API at a glance

Even though this demo's runtime drives Transform directly, the same state machine wired to a real animated character would call all of these:

// Plays the named clip exclusively in the default layer. Returns
// immediately; subsequent calls cross-fade to the new clip in
// `fadeInTime` seconds.
animator.play("walk", APJS.AnimationWrapMode.Repeat, /* speed */ 1.0, /* fadeInTime */ 0.2);

// Returns true while the named clip is the actively playing one.
if (animator.isPlaying("run")) { /* ... */ }

// Subscribes to start / end events on a specific clip — useful for
// chaining one-shot animations into a follow-up state.
const emitter = animator.getEmitter("wave");
emitter?.on(APJS.AnimationEventType.AnimationEnd, (event: APJS.IEvent) => {
const finishedClip = event.args[0] as APJS.Animation;
// Fall back to idle once "wave" finishes.
animator.play("idle", APJS.AnimationWrapMode.Repeat, 1.0, 0.15);
});

// Stop / pause / resume every active clip on this animator.
animator.stopAll();
animator.pauseAll();
animator.resumeAll();

AnimationWrapMode is the small enum that controls how a clip's playback decays at its end:

ValueBehaviour
Repeat (0)Loops back to the start. Default for cycle states.
Once (1)Plays once, then stops. Use for one-shot reactions.
PingPong (-1)Plays forward, then in reverse, then forward again.
ClampForever (-2)Plays once, then holds the final frame indefinitely.

Read the script

CharacterStateMachine.ts

@component()
export class CharacterStateMachine extends APJS.BasicScriptComponent {
// The 3D character SceneObject we drive. The script reads its Transform
// at lazy-init and mutates localPosition / localEulerAngles / localScale
// each frame to convey the active state's mood.
//
// PRODUCTION NOTE: in a real effect, this SceneObject would carry an
// `Animator` component that owns named Animation clips (idle, walk, run).
// The state machine would call `animator.play("walk", AnimationWrapMode.Repeat,
// 1.0, 0.2)` instead of the math-driven Transform mutation below — see
// the "What you learned" section of the tutorial for the canonical pattern.
@serializeProperty character!: APJS.SceneObject;

// The status label that names the active state.
@serializeProperty statusText!: APJS.SceneObject;

private characterTransform!: APJS.Transform;
private statusComp!: APJS.Text;
private touchCallback!: (e: APJS.IEvent) => void;
private stateIndex: number = 0;
private elapsed: number = 0;
private inited: boolean = false;

// Three behavioural states. Each entry packs the per-frame motion knobs:
// - bobHz / bobAmp: vertical sin-wave bounce
// - swayHz / swayAmp: side-to-side sway translation
// - rotHz: yaw rotation rate, degrees per second
// - leanDeg: forward (+x) lean of the body, in degrees
// Tuning these three rows is the entire "animation library" of this demo.
private static readonly STATES = [
{ name: "Idle", bobHz: 0.6, bobAmp: 0.10, swayHz: 0.0, swayAmp: 0.0, rotHz: 0, leanDeg: 0 },
{ name: "Walk", bobHz: 1.4, bobAmp: 0.18, swayHz: 0.7, swayAmp: 0.25, rotHz: 30, leanDeg: 6 },
{ name: "Run", bobHz: 2.4, bobAmp: 0.35, swayHz: 1.2, swayAmp: 0.55, rotHz: 90, leanDeg: 16 },
];

onUpdate(dt: number): void {
if (!this.inited) {
if (!this.character || !this.statusText) return;

this.characterTransform = this.character.getComponent("Transform") as APJS.Transform;
this.statusComp = this.statusText.getComponent("Text") as APJS.Text;

this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.advance();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);

this.applyStateLabel();
this.inited = true;
console.log("[CharacterStateMachine] ready — starting state " + CharacterStateMachine.STATES[this.stateIndex].name);
return;
}

this.elapsed += dt;
const s = CharacterStateMachine.STATES[this.stateIndex];

// Vertical bob.
const bobPhase = Math.sin(this.elapsed * s.bobHz * 2 * Math.PI);
// Horizontal sway.
const swayPhase = Math.sin(this.elapsed * s.swayHz * 2 * Math.PI);

// Drive Transform every frame. In a real Animator-driven build this
// entire block would be the animator playing the active clip; we'd only
// change `playState` / `play()` on tap.
this.characterTransform.localPosition = new APJS.Vector3f(
swayPhase * s.swayAmp,
bobPhase * s.bobAmp,
0,
);
this.characterTransform.localEulerAngles = new APJS.Vector3f(
s.leanDeg,
(this.elapsed * s.rotHz) % 360,
swayPhase * 4, // small Z-roll proportional to sway, for character
);
}

onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
}

private advance(): void {
this.stateIndex = (this.stateIndex + 1) % CharacterStateMachine.STATES.length;
this.applyStateLabel();

// PRODUCTION NOTE: the canonical Animator call replacing the math-driven
// motion would look like:
//
// const s = CharacterStateMachine.STATES[this.stateIndex];
// this.animator.play(s.name.toLowerCase(),
// APJS.AnimationWrapMode.Repeat, 1.0, 0.2);
//
// where `s.name.toLowerCase()` matches the named clip on the rigged
// character ("idle", "walk", "run"). The 4th argument is the cross-fade
// time in seconds that smooths the transition between clips.
console.log("[CharacterStateMachine] state " + CharacterStateMachine.STATES[this.stateIndex].name);
}

private applyStateLabel(): void {
const s = CharacterStateMachine.STATES[this.stateIndex];
this.statusComp.text = (this.stateIndex + 1) + " of " + CharacterStateMachine.STATES.length + "\n" + s.name;
}
}

State table

IndexNameBob (Hz / Amp)Sway (Hz / Amp)Yaw (deg/sec)Lean (deg)
0Idle0.6 / 0.100 / 000
1Walk1.4 / 0.180.7 / 0.25306
2Run2.4 / 0.351.2 / 0.559016

Each row is the same data shape an Animator would consume per clip — only the field names differ (clip name, wrap mode, speed, fade-in).

Wiring the production version (with a rigged character)

When you swap the cube for an FBX character with named clips:

  1. Drop the rigged character into the scene. The Effect House importer auto-creates an Animator component on the root and registers each named clip as an Animation resource visible in the inspector's clip list.
  2. Add a @serializeProperty animator: APJS.Animator field to CharacterStateMachine and wire it to the character's Animator in the inspector. (Note: the character SceneObject can stay wired to the existing character field too if you want both handles.)
  3. Replace the onUpdate per-frame Transform mutation with nothing — the Animator runs the clip on its own once play() is called.
  4. Replace the body of advance() with:
    const s = CharacterStateMachine.STATES[this.stateIndex];
    this.animator.play(s.name.toLowerCase(),
    APJS.AnimationWrapMode.Repeat,
    /* speed */ 1.0,
    /* fadeInTime */ 0.2);
  5. For one-shot reactions (a Wave clip that should auto-fall back to Idle), subscribe to the AnimationEnd emitter once during lazy-init:
    const waveEmitter = this.animator.getEmitter("wave");
    waveEmitter?.on(APJS.AnimationEventType.AnimationEnd, () => {
    this.animator.play("idle", APJS.AnimationWrapMode.Repeat, 1.0, 0.15);
    });

The state-machine state-names → clip-names mapping table stays identical to this demo's STATES array; only the per-frame "animation playback" implementation changes.

Customize

On GameControllerCharacterStateMachine:

  • character — the SceneObject whose Transform the script drives. In the production version, also wire its Animator to a new @serializeProperty animator: APJS.Animator field.
  • statusText — the 2D Text label SceneObject; the script looks up its Text component at runtime.
  • STATES — the canonical state table. Add a Wave row with rotHz: 360 for a spin-attack one-shot, or a Sneak row with low bob/sway for a slow exploration mode.

In the editor, on the Character SceneObject:

  • Transform.localScale — bigger character = more dramatic motion arcs. The demo uses 0.4 to fit the safe zone.
  • Material._EmissiveColor / _EmissiveIntensity — bump the emissive to make the character's silhouette pop more against the camera-feed background.

Suggestions for further play:

  • Add a long-press gesture to enter a "Charge" state with rotHz: 0 and bobAmp: 0.05 — the character squats. Releasing fires a one-shot "Jump" (large vertical arc). See the Events & Input tutorial for the GestureType.LongTap + GestureType.Drop pattern.
  • Layer the state with a VFX burst — when the user advances to "Run", play a dust-cloud VisualEffect at the character's feet.
  • Combine with the Audio tutorial: each state plays its own ambient loop. Tap = state change = audioComp.play() the matching loop, while stopping the previous.

What you learned

This tutorial used:

  • The state-machine pattern — a STATES table indexed by stateIndex, advanced on global EventType.Touch. The same table shape applies whether you drive Transform directly (this demo) or call Animator.play(...) (the production version).
  • Animator.play(name, wrapMode, speed, fadeInTime) — the one canonical call for switching clips. Cross-fade time is a simple way to soften "snappy" transitions.
  • AnimationWrapModeRepeat for cycle states, Once / ClampForever for one-shots (a Wave that holds the last frame).
  • Animator.getEmitter(name) + AnimationEventType.AnimationEnd — how to chain one-shot clips into a fall-back state.
  • @serializeProperty SceneObject for the character + status text — wired in the inspector, looked up to runtime Transform / Text / Animator references in lazy-init.

Read the full Animator reference, the Animation reference, the AnimationWrapMode reference, the AnimationEventType reference, the WrapMode reference, and the Animation namespace overview.

For the touch-event subscription pattern used here, see the Events & Input tutorial. For swapping the math-driven animation with the canonical TweenAnimation system instead of Animator, see the TweenAnimation reference under the Post-Process namespace.

Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies