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.

What you'll build
- A Character cube at the origin with
localScale = (0.4, 0.4, 0.4)wearing aStandard PBRmaterial 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
TitleTextandStatusText2D HUD that mirrors the active state (e.g.,2 of 3 / Walk). - A
GameControllerempty SceneObject hostingCharacterStateMachine— the script that subscribes to globalEventType.Touch, advances the active state, and drives the character'sTransformper frame.
Open the demo
Unzip and open in Effect House (5.9.0+). The opening scene contains:
- Camera — slightly downward-pitched perspective camera at
(0, 1, 14)withlocalEulerAngles = (-10, 0, 0), framing the character at chest height. - Character — the blue cube whose
Transformthe script mutates each frame. In a production effect, this is the rigged 3D character whoseAnimatorplays 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:
| Value | Behaviour |
|---|---|
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
| Index | Name | Bob (Hz / Amp) | Sway (Hz / Amp) | Yaw (deg/sec) | Lean (deg) |
|---|---|---|---|---|---|
| 0 | Idle | 0.6 / 0.10 | 0 / 0 | 0 | 0 |
| 1 | Walk | 1.4 / 0.18 | 0.7 / 0.25 | 30 | 6 |
| 2 | Run | 2.4 / 0.35 | 1.2 / 0.55 | 90 | 16 |
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:
- Drop the rigged character into the scene. The Effect House
importer auto-creates an
Animatorcomponent on the root and registers each named clip as anAnimationresource visible in the inspector's clip list. - Add a
@serializeProperty animator: APJS.Animatorfield toCharacterStateMachineand wire it to the character's Animator in the inspector. (Note: the character SceneObject can stay wired to the existingcharacterfield too if you want both handles.) - Replace the
onUpdateper-frame Transform mutation with nothing — theAnimatorruns the clip on its own onceplay()is called. - 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); - 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 GameController → CharacterStateMachine:
character— the SceneObject whose Transform the script drives. In the production version, also wire itsAnimatorto a new@serializeProperty animator: APJS.Animatorfield.statusText— the 2D Text label SceneObject; the script looks up itsTextcomponent at runtime.STATES— the canonical state table. Add aWaverow withrotHz: 360for a spin-attack one-shot, or aSneakrow 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 uses0.4to 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: 0andbobAmp: 0.05— the character squats. Releasing fires a one-shot "Jump" (large vertical arc). See the Events & Input tutorial for theGestureType.LongTap+GestureType.Droppattern. - Layer the state with a
VFXburst — when the user advances to "Run", play a dust-cloudVisualEffectat 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
STATEStable indexed bystateIndex, advanced on globalEventType.Touch. The same table shape applies whether you driveTransformdirectly (this demo) or callAnimator.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.AnimationWrapMode—Repeatfor cycle states,Once/ClampForeverfor 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 SceneObjectfor the character + status text — wired in the inspector, looked up to runtimeTransform/Text/Animatorreferences 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.