How to Build a Character Controller with Finite State Machines in Three.js

Introduction
A character in a game can walk, run, idle, jump, attack, die. At any given moment it is doing exactly one of those things. It transitions between them based on input or events. That is a finite state machine. A fixed set of states, a fixed set of transitions between them, and only one state active at a time.
This post covers why you want one, how to build one from scratch, how animation blending fits in, and how to structure the code so it scales when you add more states later.
Why not just use if-else
The naive approach looks like this.
Update(input, dt) {
if (input.forward) {
if (input.shift) {
playAnimation('run');
moveForward(runSpeed * dt);
} else {
playAnimation('walk');
moveForward(walkSpeed * dt);
}
} else if (input.space) {
playAnimation('dance');
} else {
playAnimation('idle');
}
}
This works for 3 states. It falls apart at 10.
The problem is transitions. When you go from walk to run, you might want to sync the leg positions so the character does not stumble. When you go from dance to idle, you want to wait until the dance finishes. When you are attacking, you want to ignore movement input until the attack animation completes.
Each of these is a special case. With if-else, every new state adds special cases to every other state. The number of edge cases grows as n * (n-1). At 10 states that is 90 possible transitions. Your if-else becomes unreadable.
A finite state machine solves this by making each state a self-contained object that handles its own enter/exit/update logic. States do not know about each other's internals. They just declare which state to transition to and when.
The architecture
Three classes.
State. A base class. Every concrete state (idle, walk, run, dance) extends this. It has three methods: Enter, Exit, Update.
class State {
constructor(parent) {
this._parent = parent; // Reference to the FSM.
}
get Name() {
return "";
}
Enter(prevState) {}
Exit() {}
Update(dt, input) {}
}
Enter is called when you transition into this state. It receives the previous state so you can do things like sync animations. Exit is called when you leave. Update runs every frame while this state is active.
FiniteStateMachine. The manager. It holds a registry of state classes and tracks the currently active state.
class FiniteStateMachine {
constructor() {
this._states = {};
this._currentState = null;
}
AddState(name, stateClass) {
this._states[name] = stateClass;
}
SetState(name) {
const prev = this._currentState;
if (prev) {
if (prev.Name === name) return; // Already in this state.
prev.Exit();
}
const next = new this._states[name](this);
this._currentState = next;
next.Enter(prev);
}
Update(dt, input) {
if (this._currentState) {
this._currentState.Update(dt, input);
}
}
}
SetState is the key method. It exits the old state, instantiates the new one, calls Enter. Notice that it passes the previous state into Enter. This is how the new state knows what it is transitioning from.
CharacterFSM. A subclass of FiniteStateMachine that registers the character-specific states.
class CharacterFSM extends FiniteStateMachine {
constructor(proxy) {
super();
this._proxy = proxy;
this.AddState("idle", IdleState);
this.AddState("walk", WalkState);
this.AddState("run", RunState);
this.AddState("dance", DanceState);
}
}
The proxy is a lightweight object that gives states access to the character's animations without exposing the entire controller. More on this later.
The states
Each state does two things: play the right animation on enter, and decide when to transition on update.
IdleState.
class IdleState extends State {
get Name() {
return "idle";
}
Enter(prevState) {
const idleAction = this._parent._proxy.animations["idle"].action;
if (prevState) {
const prevAction = this._parent._proxy.animations[prevState.Name].action;
idleAction.time = 0.0;
idleAction.enabled = true;
idleAction.setEffectiveTimeScale(1.0);
idleAction.setEffectiveWeight(1.0);
idleAction.crossFadeFrom(prevAction, 0.5, true);
idleAction.play();
} else {
idleAction.play();
}
}
Update(dt, input) {
if (input.forward || input.backward) {
this._parent.SetState("walk");
} else if (input.space) {
this._parent.SetState("dance");
}
}
}
On enter: play the idle animation. If there was a previous state, crossfade from it over 0.5 seconds. On update: if the player presses forward or backward, transition to walk. If space, transition to dance. That is it. The idle state does not know how walking works. It just says "go to walk" and the walk state handles the rest.
WalkState.
class WalkState extends State {
get Name() {
return "walk";
}
Enter(prevState) {
const curAction = this._parent._proxy.animations["walk"].action;
if (prevState) {
const prevAction = this._parent._proxy.animations[prevState.Name].action;
curAction.enabled = true;
if (prevState.Name === "run") {
// Sync the walk animation to where the run animation was.
const ratio =
curAction.getClip().duration / prevAction.getClip().duration;
curAction.time = prevAction.time * ratio;
} else {
curAction.time = 0.0;
curAction.setEffectiveTimeScale(1.0);
curAction.setEffectiveWeight(1.0);
}
curAction.crossFadeFrom(prevAction, 0.5, true);
curAction.play();
} else {
curAction.play();
}
}
Update(dt, input) {
if (input.forward || input.backward) {
if (input.shift) {
this._parent.SetState("run");
}
} else {
this._parent.SetState("idle");
}
}
}
The interesting part is the run-to-walk sync. Walk and run animations have different durations. If the run animation is 40% through its cycle, you want the walk animation to also start at 40%. Otherwise the legs jump to a different position during the crossfade. The ratio math handles this:
const ratio = walkClip.duration / runClip.duration;
walkAction.time = runAction.time * ratio;
Without this, the transition looks like the character stumbles. With it, the legs stay in sync and the blend is smooth.
RunState. Nearly identical to WalkState. The only difference is the update logic: if shift is released, go to walk. If no movement keys, go to idle. The enter logic does the same ratio sync but in reverse (syncing run to walk).
DanceState.
class DanceState extends State {
get Name() {
return "dance";
}
Enter(prevState) {
const curAction = this._parent._proxy.animations["dance"].action;
const mixer = curAction.getMixer();
// Listen for when the animation finishes.
this._finishedCallback = () => {
this._parent.SetState("idle");
};
mixer.addEventListener("finished", this._finishedCallback);
if (prevState) {
const prevAction = this._parent._proxy.animations[prevState.Name].action;
curAction.reset();
curAction.setLoop(THREE.LoopOnce, 1);
curAction.clampWhenFinished = true;
curAction.crossFadeFrom(prevAction, 0.2, true);
curAction.play();
} else {
curAction.play();
}
}
Exit() {
const action = this._parent._proxy.animations["dance"].action;
action.getMixer().removeEventListener("finished", this._finishedCallback);
}
Update(dt, input) {
// Does nothing. Waits for animation to finish.
}
}
Dance is different from the other states. It plays once and returns to idle when done. setLoop(THREE.LoopOnce, 1) makes it play exactly once. clampWhenFinished = true freezes on the last frame instead of snapping back. The finished event listener triggers the transition back to idle.
Notice that Update is empty. While dancing, input is ignored. The character cannot walk or run until the dance finishes. This is one of the things that is hard to do with if-else but trivial with an FSM. The dance state simply does not check input.
The Exit method removes the event listener. This is important. Without cleanup, the listener would fire again on a future dance animation and cause bugs.
The proxy pattern
States need access to animation data. But you do not want them accessing the entire character controller. The proxy is a thin wrapper that exposes only what states need.
class BasicCharacterControllerProxy {
constructor(animations) {
this._animations = animations;
}
get animations() {
return this._animations;
}
}
The controller creates the proxy and passes it to the FSM:
this._stateMachine = new CharacterFSM(
new BasicCharacterControllerProxy(this._animations),
);
States access animations through this._parent._proxy.animations. They cannot call controller methods they should not call. This is about keeping boundaries clean. It matters more as the codebase grows.
The controller itself
The controller ties everything together. It owns the model, the input, the FSM, and the movement logic.
class BasicCharacterController {
constructor(params) {
this._velocity = new THREE.Vector3(0, 0, 0);
this._deceleration = new THREE.Vector3(-0.0005, -0.0001, -5.0);
this._acceleration = new THREE.Vector3(1, 0.25, 50.0);
this._input = new BasicCharacterControllerInput();
this._animations = {};
this._stateMachine = new CharacterFSM(
new BasicCharacterControllerProxy(this._animations),
);
this._LoadModels();
}
Update(dt) {
if (!this._target) return;
// Let the FSM handle state transitions and animation.
this._stateMachine.Update(dt, this._input);
// Apply movement.
const velocity = this._velocity;
// Deceleration (friction).
const friction = new THREE.Vector3(
velocity.x * this._deceleration.x,
velocity.y * this._deceleration.y,
velocity.z * this._deceleration.z,
);
friction.multiplyScalar(dt);
velocity.add(friction);
// Acceleration from input.
if (this._input._keys.forward) {
velocity.z += this._acceleration.z * dt;
}
if (this._input._keys.backward) {
velocity.z -= this._acceleration.z * dt;
}
// Rotation from left/right input.
if (this._input._keys.left) {
const q = new THREE.Quaternion();
q.setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
4.0 * Math.PI * dt * this._acceleration.y,
);
this._target.quaternion.multiply(q);
}
if (this._input._keys.right) {
const q = new THREE.Quaternion();
q.setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
-4.0 * Math.PI * dt * this._acceleration.y,
);
this._target.quaternion.multiply(q);
}
// Move in the direction the character is facing.
const forward = new THREE.Vector3(0, 0, 1);
forward.applyQuaternion(this._target.quaternion);
forward.normalize();
forward.multiplyScalar(velocity.z * dt);
this._target.position.add(forward);
// Advance the animation mixer.
if (this._mixer) this._mixer.update(dt);
}
}
The FSM handles which animation plays and when to switch. The controller handles physics (velocity, deceleration, rotation). Clean separation.
Why this matters
The FSM pattern gives you three things.
Isolation. Each state is a self-contained class. Adding a jump state means writing one new class. You define when to enter it (from idle or walk), what it does on enter (play jump animation), what it does on update (check if grounded), and when to exit (transition to idle on landing). No other state needs to change.
Transition control. The Enter(prevState) pattern lets each state handle the transition from any previous state. Walk-to-run needs animation syncing. Idle-to-walk does not. Each state decides for itself. The FSM does not need a global transition table.
Input blocking. Some states should ignore input. Dance ignores movement. A future "hit stun" state could ignore everything. With an FSM you just leave Update empty for that state. With if-else you would need guards everywhere.
Extending this
Some directions this can go.
AI characters. Replace BasicCharacterControllerInput with an AI input class that sets the same keys based on game logic instead of keyboard events. The FSM and controller do not change at all. This is why decoupling input from the controller matters.
More states. Jump, attack, hit, death, crouch, climb. Each is one class. The FSM scales linearly, not quadratically.
Blend trees. For more complex animation blending (like mixing upper body and lower body independently), you can nest FSMs or add a blend layer on top. The base pattern stays the same.
Hierarchical FSMs. A "combat" super-state that contains sub-states like "attack", "block", "parry". The outer FSM transitions between "exploration" and "combat". The inner FSM handles combat-specific states. Each level is a simple FSM. The complexity is managed by nesting, not by making one giant machine.





