steering
Import from @pierre/ecs/modules/steering.
Reynolds steering behaviours as pure functions. Each behaviour takes an
agent’s position / velocity (and, for flocking, its neighbours) and
returns a steering force — desired − velocity — in generic
Vec2 {x, y}. Blend several with combine, then apply the result as
acceleration and let @pierre/ecs/modules/motion integrate it.
Based on Craig Reynolds’ Steering Behaviors For Autonomous Characters
(1987). See examples/boids for a worked example.
Force model (why forces, not desired velocities)
Section titled “Force model (why forces, not desired velocities)”A behaviour returns desired − velocity, not the desired velocity
itself. Returning the force is what lets multiple behaviours blend with
momentum — the flocking-quality difference over “just set
velocity = dir × speed”. The constant-speed snap case (an enemy that
instantly points at the player) is the degenerate form and is already
covered by math’s vec2ScaleToLength; reach for steering when you want
smoothed or composed motion.
Per-tick application (what the consumer does):
const accel = combine([ { force: separation(pos, neighbors, vel, maxSpeed), weight: 1.8 }, { force: alignment(vel, neighbors, maxSpeed), weight: 1.0 }, { force: cohesion(pos, neighbors, vel, maxSpeed), weight: 0.9 },], maxForce);vel = truncate({ x: vel.x + accel.x * dt, y: vel.y + accel.y * dt }, maxSpeed);interface Vec2 { x: number; y: number } // re-used from modules/mathinterface Neighbor { position: Vec2; velocity: Vec2 }interface WeightedForce { force: Vec2; weight: number }interface WanderState { angle: number }interface WanderParams { distance: number; radius: number; jitter: number; random?: () => number }interface CircleObstacle { position: Vec2; radius: number }interface Segment { start: Vec2; end: Vec2 }interface WallFollowParams { desiredDistance: number; range: number }interface Path { points: readonly Vec2[]; radius: number }
// Target behavioursfunction seek(pos, target, vel, maxSpeed): Vec2function flee(pos, target, vel, maxSpeed): Vec2function arrive(pos, target, vel, maxSpeed, slowRadius): Vec2function pursue(pos, vel, targetPos, targetVel, maxSpeed): Vec2function evade(pos, vel, targetPos, targetVel, maxSpeed): Vec2
// Meanderingfunction wander(vel, state, params, maxSpeed): Vec2 // mutates state.angle
// Flocking (Reynolds boids)function separation(pos, neighbors, vel, maxSpeed): Vec2function alignment(vel, neighbors, maxSpeed): Vec2function cohesion(pos, neighbors, vel, maxSpeed): Vec2
// Environmentfunction obstacleAvoidance(pos, vel, obstacles, maxSpeed, maxSeeAhead): Vec2function wallFollowing(pos, vel, walls, params, maxSpeed): Vec2function pathFollowing(pos, vel, path, maxSpeed, predictDistance): Vec2
// Compositionfunction combine(forces: WeightedForce[], maxForce): Vec2function truncate(v: Vec2, max): Vec2Environment behaviours
Section titled “Environment behaviours”obstacleAvoidancesteers laterally around the nearest circular obstacle in the agent’s path. It projects eachCircleObstaclecentre onto the heading ray and, for those withinmaxSeeAheadwhose perpendicular distance is inside the radius, pushes away from the closest one ahead. Returns the zero vector when nothing threatens — or when the agent is stationary, since there is no heading to project. Obstacles are circles — the standard avoidance primitive; polygon obstacles are a separate, heavier shape and are out of scope.wallFollowingfollows the nearest wallSegmentwithinparams.range, holdingparams.desiredDistance. It blends a tangential term (move along the wall in the direction the agent already travels) with a normal correction that restores the gap. Returns zero when no wall is in range, or when the agent sits exactly on the wall (no side to pick). Wall following has no single standard shape across engines, so this API is our own.pathFollowingis Reynolds’ stateless predictive form: predict where the agent is heading, and if that point strays outside the corridor (path.radius), seek back toward a target nudged forward along the nearest segment; otherwise return zero. No waypoint index is threaded through — the nearest segment is re-derived each call. It keeps the agent on the path but does not stop at the final point; compose witharrivefor the last leg.
- ECS-decoupled. Steering takes plain
Vec2/Neighborvalues, not component stores. The consumer builds neighbour arrays from its own spatial index (e.g.ContinuousHashGrid2D.queryNear) and adapts itsVelocityDef {vx, vy}toVec2 {x, y}at the call boundary. - Cross-module dependency. Imports
vec2Normalize/vec2ScaleToLength/Vec2from@pierre/ecs/modules/math(same pattern ascollision → math). No other module dependency. - Zero-neighbour safety. The flocking behaviours return the zero
vector when the neighbour list is empty;
vec2Normalize/vec2ScaleToLengthreturn zero for a zero-length input, so degenerate inputs never produceNaN.