Skip to content

Steering

@yagejs-addons/steering is movement-level AI: the logic that turns “chase the player”, “keep your distance”, or “flock with your neighbors” into a velocity every frame. The root entry is pure @yagejs/core — no pixi, no physics, no input. Physics integration lives behind @yagejs-addons/steering/physics, which adds a mount-and-go agent for RigidBodyComponent entities.

Terminal window
npm install @yagejs-addons/steering
# engine peer (single install, reused — not bundled):
npm install @yagejs/core
# only if you import @yagejs-addons/steering/physics:
npm install @yagejs/physics
# only for the pathfinding-fed followPath example below:
npm install @yagejs/pathfinding

@yagejs/core is the required peer; @yagejs/physics is optional and only needed for the /physics entry. There’s no /presenters subpath, because steering has no view to swap.

The default output integrates the entity’s Transform directly — no physics body, no extra wiring:

import { SteeringAgent, seek } from "@yagejs-addons/steering";
import { Transform } from "@yagejs/core";
// enemy is an Entity with a Transform. Default output:
// position += velocity * dt. Nothing else to wire.
enemy.add(
new SteeringAgent({
maxSpeed: 120,
behaviors: [seek(() => player.get(Transform).position)],
}),
);

SteeringAgent is a @yagejs/core Component: ComponentUpdateSystem drives its update(dt) automatically once it’s added to an entity. It assumes the entity is root-level — the default output writes transform.position (local), so local and world coordinates need to match, which only holds without a parent.

PhysicsSteeringAgent (from the /physics entry) finds the entity’s RigidBodyComponent itself — mount it next to a body and collider and it drives them. Add the body before the agent; the agent reads the body’s type when it is added:

import { arrive } from "@yagejs-addons/steering";
import { PhysicsSteeringAgent } from "@yagejs-addons/steering/physics";
import { ColliderComponent, RigidBodyComponent } from "@yagejs/physics";
enemy.add(new RigidBodyComponent({ type: "dynamic", gravityScale: 0 }));
enemy.add(new ColliderComponent({ shape: { type: "circle", radius: 10 }, density: 1 }));
enemy.add(
new PhysicsSteeringAgent({
maxSpeed: 130,
maxAcceleration: 500, // default 4 x maxSpeed — see impulse drive below
behaviors: [arrive(() => waypoint, { slowRadius: 140 })],
}),
);

It defaults to impulse drive: steering composes with the physics world instead of overriding it. A dynamic agent shoves lighter bodies out of its way, gets deflected when something hits it, and a knockback impulse sends it flying — then steering pulls it back on course at maxAcceleration. That cap does two things: external impulses persist instead of being cancelled on the next tick, and direction changes come out as curves rather than frame-to-frame flips. It defaults to 4 × maxSpeed (top speed in 0.25 s); pass Infinity for an instant snap.

Pass drive: "velocity" for full-authority movers: the agent writes the commanded velocity with setVelocity every frame, so an external impulse decays at maxAcceleration instead of composing with steering. Both drives need a dynamic body — YAGE kinematic bodies are position-based and ignore setVelocity and applyImpulse.

On a kinematic body, PhysicsSteeringAgent switches output by itself: it integrates the Transform in fixedUpdate, and the physics system syncs that pose to the body each step (see Rigid Bodies). The agent then pushes dynamic bodies and is never pushed back. Passing drive alongside a kinematic body throws; so does mounting on a static body. Any agent can also opt into fixed-step steering directly with tick: "fixedUpdate".

On the root class, the same integration is the structural body option — no physics import, and a custom mover works by implementing the two methods:

import { SteeringAgent, arrive } from "@yagejs-addons/steering";
import { RigidBodyComponent } from "@yagejs/physics";
enemy.add(
new SteeringAgent({
maxSpeed: 130,
maxAcceleration: 500,
behaviors: [arrive(() => waypoint)],
body: enemy.get(RigidBodyComponent), // { setVelocity, getVelocity } — or your own object
}),
);

With a body, behaviors and the acceleration ramp read the body’s actual velocity each frame — a wall pin, a contact, a knockback all feed back into steering instead of the model computing over them.

Two options govern how behaviors combine. weight (default 1) sets how strongly a behavior contributes within a tier: contributions are summed, each scaled by its weight, then clamped to maxSpeed. priority (default 0) sets which tier wins: tiers are checked highest-first each frame, and the first whose weighted sum is non-zero wins outright — lower tiers aren’t evaluated that frame.

Weights shade a blend:

import { SteeringAgent, flee, wander } from "@yagejs-addons/steering";
enemy.add(
new SteeringAgent({
maxSpeed: 90,
faceHeading: true, // rotate the Transform to face travel direction
behaviors: [
flee(() => player.get(Transform).position, { radius: 200, weight: 2 }),
wander({ weight: 0.5 }),
],
}),
);

Priority makes a behavior an override — the classic use is obstacle avoidance that must never be out-voted by seek:

behaviors: [
seek(() => player.position),
avoidObstacles(() => rocks, { priority: 1 }), // near a rock, this steer wins outright
],

With everything on the default priority 0 the model is a plain weighted sum. A higher tier that returns ZERO (nothing to avoid) passes the frame down, so seek runs normally. An overridden stateful behavior (a path follower, a wander) pauses rather than advancing blind.

Two discovery modes. The portable one: obstacles and neighbors are game-supplied arrays — a plain list, or a provider for a live one (providers receive the agent’s state, so agent-relative sources work too):

import { SteeringAgent, seek, avoidObstacles } from "@yagejs-addons/steering";
enemy.add(
new SteeringAgent({
maxSpeed: 120,
behaviors: [
seek(() => player.position),
avoidObstacles(
() => rocks.map((r) => ({ position: r.position, radius: r.radius })),
{ lookAhead: 80, priority: 1 }, // overrides seek when a rock is in the path
),
],
}),
);

The physics one (/physics entry): avoidColliders raycasts the real world along the heading — a center ray plus two whiskers — and steers away from whatever it hits. No obstacle list to maintain; tilemap walls, crates, and every other collider count, and the agent’s own collider is excluded automatically:

import { avoidColliders } from "@yagejs-addons/steering/physics";
import { PhysicsWorldKey } from "@yagejs/physics";
const world = this.use(PhysicsWorldKey); // in a Scene's onEnter
enemy.add(
new SteeringAgent({
maxSpeed: 120,
behaviors: [
seek(() => player.position),
avoidColliders(world, { lookAhead: 90, priority: 1 }),
],
}),
);

physicsNeighbors does the same for flocking: a NeighborsSource backed by PhysicsWorld.queryRadius around the agent, so anything with a collider in range counts as a neighbor (entities without a body count as stationary):

import { physicsNeighbors } from "@yagejs-addons/steering/physics";
const nearby = physicsNeighbors(world, { radius: 60 });
boid.add(
new SteeringAgent({
maxSpeed: 100,
behaviors: [separation(nearby, { weight: 1.5 }), alignment(nearby), cohesion(nearby)],
}),
);

Flocking (boids) composes from three primitives — separation, alignment, cohesion — each reading the same neighbor list:

import { SteeringAgent, separation, alignment, cohesion } from "@yagejs-addons/steering";
for (const boid of boids) {
const neighbors = () =>
boids.filter((b) => b !== boid).map((b) => ({ position: b.position, velocity: b.velocity }));
boid.entity.add(
new SteeringAgent({
maxSpeed: 100,
behaviors: [
separation(neighbors, { radius: 30, weight: 1.5 }),
alignment(neighbors, { radius: 60 }),
cohesion(neighbors, { radius: 60, weight: 0.8 }),
],
}),
);
}

followPath walks a plain waypoint list — @yagejs/pathfinding’s Path.waypoints feeds it directly, and so does any hand-authored route. contain steers an agent back inside a rectangle before it leaves:

import { SteeringAgent, followPath, contain, wander } from "@yagejs-addons/steering";
import { defineEvent } from "@yagejs/core";
// `grid` is a @yagejs/pathfinding GridGraph — see the pathfinding guide.
// A patrol looping its route forever:
guard.add(
new SteeringAgent({
maxSpeed: 120,
behaviors: [followPath(patrolPoints, { loop: true })],
}),
);
// A pathfinding result, walked once with an arrive-style stop at the end:
const Arrived = defineEvent("enemy:arrived");
const path = grid.findPath(start, goal);
if (path) {
enemy.add(
new SteeringAgent({
maxSpeed: 140,
behaviors: [followPath(path.waypoints, { onArrive: () => enemy.emit(Arrived) })],
}),
);
}
// A wanderer that never drifts off the field:
critter.add(
new SteeringAgent({
maxSpeed: 70,
behaviors: [wander(), contain({ x: 0, y: 0, width: 900, height: 600 }, { weight: 2 })],
}),
);

Waypoint progress lives inside the behavior — to follow a new path, swap in a new followPath (agent.setBehaviors([...])). For save/restore, snapshot the returned behavior’s waypointIndex and pass it back as startAt; a plain startAt: "nearest" enters the path at the closest waypoint (restored agents and mid-route attach both skip the walk back to waypoint 0).

Skip the Component entirely and drive the Steering model yourself — your own loop, your own integration:

import { Steering, seek } from "@yagejs-addons/steering";
import { Vec2 } from "@yagejs/core";
const steering = new Steering([seek(() => target)]);
let pos = new Vec2(0, 0);
let vel = Vec2.ZERO;
// each tick:
vel = steering.compute({ position: pos, velocity: vel, maxSpeed: 120 }, dt);
pos = pos.add(vel.scale(dt));

The hosted model and the commanded velocity are always readable and mutable — nothing here is locked behind construction-time config:

agent.steering.add(flee(() => boss.position, { weight: 4 })); // add a behavior live
agent.maxSpeed = 200; // retune
agent.velocity; // Vec2 the agent is steering toward — draw a debug arrow with it
agent.stop(); // halt now: zeroes the model AND the body/output
agent.enabled = false; // pause ticking without removing the component

Every factory returns a SteeringBehavior and takes an options object with at least weight (default 1) and priority (default 0). Targets, obstacles, and neighbors accept either a static value or a provider (agent) => value, resolved fresh each tick with the agent’s state.

BehaviorSignatureNotes
seekseek(target, opts?)Straight toward target at full speed.
fleeflee(target, { radius? })Straight away; radius gates it to nearby threats only.
arrivearrive(target, { slowRadius?, arriveRadius?, onArrive?, onDepart? })Ramps speed down inside slowRadius, settles inside arriveRadius.
wanderwander({ distance?, radius?, jitter?, random? })Slowly-turning circle ahead of the current heading.
pursue / evadepursue(target, { maxPrediction? })target is { position, velocity }; leads the predicted position.
avoidObstaclesavoidObstacles(obstacles, { lookAhead?, agentRadius? })Look-ahead ray against a supplied circle list.
avoidCollidersavoidColliders(world, { lookAhead?, whiskerAngle?, whiskerLength? })/physics entry: raycasts the real world, center ray + whiskers.
separation / alignment / cohesionseparation(neighbors, { radius? })The three boid rules; neighbors is { position, velocity }[].
followPathfollowPath(waypoints, { waypointRadius?, loop?, startAt?, slowRadius?, arriveRadius?, onArrive?, onDepart? })Waypoints in order; loop patrols forever, otherwise arrives at the end.
containcontain(bounds, { lookAhead? })Steers back inside { x, y, width, height } before crossing an edge.

arrive’s and followPath’s onArrive/onDepart are the addon’s discrete consequences — fire your own entity/bus event from inside them in a line, if the game wants one:

const ReachedWaypoint = defineEvent("enemy:reached-waypoint");
arrive(waypoint, {
onArrive: () => enemy.emit(ReachedWaypoint),
});

To visualize steering, read agent.velocity and draw the arrow yourself, as the steering example does. Arrival is delivered through the onArrive callback — mirror it to your own event in a line if you want one.

Steering state is transient and re-derives, so there’s nothing to serialize. The one piece of progress worth keeping is followPath’s waypoint index: read waypointIndex, store it with your save, and pass it back as startAt.