Excerpt: A finite state machine gives game AI a small set of explicit behaviors and predictable transitions. In HTML5 games, this structure keeps enemies understandable, testable, and efficient without requiring a heavyweight AI framework.
Enemy AI often begins as a few conditions inside an update loop. If the player is close, attack. Otherwise, move toward a waypoint. As features grow, the same loop collects timers, visibility checks, animation flags, cooldowns, and special cases. Soon an enemy can attack while stunned, play the wrong animation, or switch behavior every frame.
A finite state machine, or FSM, replaces that tangled logic with named states and controlled transitions. Each state owns one behavior, while transition rules decide when the AI moves to another state. The result is easier to reason about and easier to profile on desktop and mobile browsers.
Start with the smallest useful state set
Do not model every animation as a separate AI state. Begin with behaviors that make different decisions:
- Idle: wait, observe, or play an ambient action.
- Patrol: follow waypoints or wander inside a region.
- Investigate: move toward a sound or last known player position.
- Chase: pursue a visible or recently detected target.
- Attack: perform a weapon or contact action under clear conditions.
- Stunned: suspend ordinary decisions for a bounded duration.
- Return: move back to the assigned area after losing the target.
Not every game needs all of these. A simple arcade enemy may use only patrol, chase, and attack. Smaller machines are easier to test and less likely to contain unreachable or contradictory transitions.
Give every state a clear lifecycle
A practical state interface has three methods:
class State {
enter(context, previousState) {}
update(context, dt) {}
exit(context, nextState) {}
}
enter initializes state-specific timers, chooses an animation, or requests a path. update performs the behavior for one simulation step and may request a transition. exit cancels temporary effects or releases resources.
Keep shared data in a context object: position, velocity, target ID, perception results, navigation service, animation controller, and deterministic random source. States should not secretly reach into unrelated global objects.
Centralize transitions
A small controller should own the active state and perform transitions in one place:
class StateMachine {
constructor(context, initialState) {
this.context = context;
this.state = null;
this.change(initialState);
}
change(nextState) {
if (nextState === this.state) return;
const previous = this.state;
if (previous) previous.exit(this.context, nextState);
this.state = nextState;
this.state.enter(this.context, previous);
}
update(dt) {
const next = this.state.update(this.context, dt);
if (next && next !== this.state) {
this.change(next);
}
}
}
Returning a requested next state keeps changes visible. Avoid letting unrelated systems replace the state at arbitrary points during the same update. A single transition boundary prevents double exits, skipped initialization, and inconsistent animation changes.
Write transitions as explicit rules
A patrol state can express its priorities directly:
class PatrolState {
enter(ctx) {
ctx.animation.play("walk");
}
update(ctx, dt) {
if (ctx.health <= 0) return states.defeated;
if (ctx.stunRemaining > 0) return states.stunned;
if (ctx.perception.canAttack) return states.attack;
if (ctx.perception.canSeeTarget) return states.chase;
ctx.followPatrolPath(dt);
return null;
}
exit(ctx) {
ctx.stopPathRequest();
}
}
Order represents priority. Defeat or stun rules should usually run before attack and navigation. Document that order so later features do not accidentally make a low-priority transition override a critical one.
Separate perception from behavior
Do not let every state run its own raycasts, distance scans, or neighborhood queries. A perception system can compute a compact result once per simulation step:
{
targetId: 42,
distance: 186,
hasLineOfSight: true,
canAttack: false,
lastSeenPosition: { x: 812, y: 244 },
secondsSinceSeen: 0.12
}
States consume this snapshot. Centralized perception prevents duplicated work and makes decisions testable with synthetic inputs. When many enemies search for nearby targets, a broad-phase structure such as the spatial hash grid for HTML5 games can reduce candidate scans.
Use hysteresis to prevent state thrashing
If chase begins at 300 pixels and ends at exactly 300 pixels, an enemy near the boundary may alternate between patrol and chase every frame. Use different enter and exit thresholds:
- enter chase when distance is below 280 pixels;
- leave chase when distance exceeds 340 pixels;
- enter attack below 70 pixels;
- leave attack above 90 pixels.
Short minimum-state durations can also stabilize behavior, but they should not block critical transitions such as defeat or stun. Treat emergency rules separately from ordinary movement decisions.
Drive AI from the simulation step
State updates should receive seconds, not assume a fixed rendering frame count. Timers, movement, cooldowns, and perception memory must use simulation time. The techniques in a fixed-timestep game loop keep state behavior stable when rendering slows or the display refresh rate changes.
For large crowds, not every AI needs a full decision update every simulation step. Movement can remain smooth while expensive perception updates are staggered across several frames. Give each enemy a stable phase offset so workload is distributed predictably.
Coordinate states with animation
The AI state describes intent; the animation controller describes presentation. Keep them related but not identical. An attack state may play wind-up, strike, and recovery clips without becoming three unrelated AI states.
Use explicit events for important moments such as spawning a projectile or enabling a hitbox. Make sure those events are deterministic and cannot fire twice when an animation is interrupted. If an action must remain responsive to player timing or networking, a bounded event queue similar to HTML5 input buffering can preserve intent across simulation boundaries.
Handle interrupts deliberately
Stun, knockback, scripted sequences, and defeat can interrupt normal behavior. Define which transitions are allowed from every state. A simple global transition layer can check health and status effects before delegating to the active state's ordinary logic.
When leaving an interrupted state, decide whether the enemy resumes the previous state or evaluates behavior again. Re-evaluation is often safer because the target may have moved. If resumption is required, store a bounded previous-state reference rather than an unbounded stack.
Keep state data out of shared singletons
States can be shared immutable objects when all per-enemy data lives in the context. Timers, destinations, and current targets must not be fields on a shared state instance. Otherwise, one enemy can overwrite another enemy's behavior.
If states require many temporary objects, reuse context-owned buffers. Measure before adding pools. The guide to object pooling in HTML5 games explains how to reset pooled data completely and keep growth bounded.
Make randomness reproducible
Wander directions, reaction delays, and attack choices often use randomness. Inject a random-number source into the context instead of calling Math.random() inside states. Seeded randomness makes bugs reproducible and supports reliable replays. See the approach in deterministic random seeds for HTML5 games.
Test transitions without rendering
An FSM becomes valuable when it can be tested as pure behavior. Construct a context with fake perception data, call updates, and assert the requested next state.
- patrol transitions to chase when the target becomes visible;
- chase returns after the visibility memory expires;
- attack respects range and cooldown;
- stun overrides movement but not defeat;
- exit cleanup runs exactly once;
- the same seeded inputs produce the same state sequence.
Also test oscillation around thresholds and large frame-time spikes. A long pause should not cause dozens of attacks to execute at once when the tab resumes.
Instrument the machine
Record state transition counts, time spent per state, perception cost, and update time. During debugging, keep a short per-enemy transition history:
12.40 patrol -> chase (target_visible)
14.05 chase -> attack (in_range)
14.72 attack -> chase (target_out_of_range)
18.10 chase -> return (memory_expired)
Feed aggregate timing into the same diagnostics used for frame-time telemetry. If a state causes spikes, the history reveals whether the cost comes from path requests, perception, spawning, or animation events.
Know when an FSM is not enough
A flat state machine works well for compact enemy behavior. If states multiply into combinations such as stunned-airborne-attacking, consider a hierarchical state machine with shared parent behavior. Utility scoring can help when many competing actions need continuous prioritization. Behavior trees can represent longer sequences.
Do not switch architectures only because they sound more advanced. An explicit FSM is often the best choice for arcade enemies, bosses with clear phases, menus, game flow, and interactive objects.
Implementation checklist
- Start with the smallest set of behavior states.
- Give states explicit enter, update, and exit methods.
- Centralize transitions in one controller.
- Apply critical global rules before ordinary state logic.
- Separate perception results from behavior decisions.
- Use different enter and exit thresholds to prevent thrashing.
- Update timers from simulation time, not rendering frames.
- Keep per-enemy data in the context, not shared state objects.
- Inject seeded randomness for reproducible behavior.
- Test transitions without rendering and record transition history.
Final takeaway
A good finite state machine makes AI behavior visible. Every state has one job, every transition has a reason, and every interruption follows a known priority. That clarity matters more than architectural complexity. Build the smallest machine that expresses the enemy's behavior, measure it under real workloads, and expand it only when the design truly needs another state.