Locomotion
A character's walking and running as an aspect: the game states an intent every frame — which
way to go, at which gait, where to face — and the engine moves the body and picks what the
Animator's base layer shows (the idle, a start, the gait loop, a turn, a stop, a turn
on the spot). The collider it moves is a CharacterController; a speed axis you drive
yourself is a blend loop (anim.playLoop({ Idle: 0, Walk: 1.5, Run: 5 })), not this.
- walk and run a character with WASD or a stick, its animations picked for it
- play idle, start, stop and turn animations automatically as it moves
- use root-motion or in-place animation clips for the movement
- face the aim while strafing in another direction
- play a roll, dodge or attack that moves the body
- stop the feet sliding when the speed does not match the clip
- tune acceleration, turning and braking
Platforms: iOS, Android, desktop, the web player and the le.codes viewer app; not on the web
(Web Lite) or in headless tests (lecodes test without --sim / --desktop) — check
Locomotion.supported; there attaching warns once, the body never moves and state stays 'idle'
(test movement with --sim). anim.warp
runs on every host.
At a glance
class PlayerInput extends Aspect<'playerInput', Node> {
static before = [Locomotion] // state the intent before the Locomotion reads it
updateBefore(): void {
const x = (Input.key('KeyA') ? 1 : 0) - (Input.key('KeyD') ? 1 : 0)
const z = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
const loco = this.node.get(Locomotion)!
if (x === 0 && z === 0) loco.move(null) // stop: the body keeps the facing it was asked for
else loco.move([x, 0, z], { gait: Input.key('ShiftLeft') ? 'run' : 'walk' })
}
}
const main = async () => {
const scene = new Scene()
scene.add(Mesh.box({ size: [40, 1, 40], position: [0, -0.5, 0] })
.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' }))
const model = await Model.load(asset('./hero.glb')) // clips: Idle, Walk, Run — recorded in place
model.position = [0, -0.9, 0] // the capsule's bottom
const player = new Node()
.aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
.aspect(CharacterController)
.aspect(Locomotion, {
animator: model.anim,
set: { idle: 'Idle', gaits: ['Walk', 'Run'] },
displacement: 'code', // in-place clips: the simulation moves the body
tuning: { speeds: { walk: 1.4, run: 4 } }, // m/s
})
.aspect(PlayerInput)
player.add(model)
player.position = [0, 1, 0]
scene.add(player)
player.loco.on('state', (state) => console.log(`${state} at ${player.loco.speed.toFixed(2)} m/s`))
scene.open()
}
main()Set up a character: Locomotion options {#options}
node.aspect(Locomotion, { animator?, set, displacement?, tuning?, warp?, feet? })
node.loco.rebuild() // re-read set + tuning (re-measures the clips) — after changing either at runtime
node.loco.retune() // re-push the tuning alone (a debug slider, a character that gets heavier)| Option | Default | Meaning |
|---|---|---|
animator |
the first Animator on the node or under it | Whose clips are shown. It must exist at attach — add the model first, or pass it |
set |
{} |
The clips the selector may show (below) |
displacement |
'hybrid' |
Who moves the body (below) |
tuning |
{} |
The feel (below) |
warp |
off | Sets anim.warp: true or WarpOptions |
feet |
{ lock: true } |
Sets anim.feet: true = lock + ground IK, false = neither, or the options |
Which animations it plays: the clip set, LocomotionSet {#the-set}
The clips the selector may show, by kind. The engine measures what each clip does itself (how far it
turns, how fast it travels, where its feet land). An entry is a clip name of the animator, or
{ clip, gait?, angle?, speed? } where the measurement needs help: gait says which gait a start / stop / turn belongs to
(left out = any), angle overrides the turn a clip makes (degrees, + = left), speed a gait clip's
m/s. Everything but idle is a list, and an empty list means the engine never shows that kind (a pack
without turns steers through them):
{ idle, gaits: [walk, run, sprint], // the gait loop's members, slowest first — blended by the simulated speed
starts, stops, turns, // standing → moving, moving → standing, turns while moving
spins, // turns on the spot
strafes } // one gait recorded in every direction — the move(dir, { face, strafe: true }) blendRoot-motion or in-place clips: displacement {#displacement}
Who moves the body:
'hybrid'(default) — the clips' recorded motion moves it; while the gait loop shows it is adjusted toward what the simulation wants by at mosttuning.adjustm/s, and a start / turn / stop / spin moves it alone. For packs authored with root motion.'code'— the simulation moves it. For packs recorded in place (their clips travel nowhere, so givetuning.speeds).'data'— the clips' root motion moves it and nothing adjusts it; the simulation only predicts.
The body is the node the aspect is on: with a CharacterController on it or an
ancestor it gets the velocity through controller.move() (so it collides), else its position is
integrated. The node's yaw is written every frame (under 'data' the root motion turns it).
Move, face and strafe: loco.move, loco.face {#the-intent}
loco.move(direction: Vec3Like | null, { gait?, face?, strafe? }) // restate every frame; null = no movement
loco.face(direction: Vec3Like | number) // a facing on its own (a heading in degrees)
loco.gait // 'walk' | 'run' | 'sprint' — the gait asked for (settable)
loco.moving / loco.direction / loco.heading / loco.intentSpeed // the intent as it stands (heading °, speed m/s)directionis a WORLD direction on the ground (its y is ignored); its length is the stick's pull, clamped to 1, and undertuning.deadZoneit counts as released. Headings are degrees, 0 = +Z,- = toward +X.
nullstops the body, not its facing: the facing intent outlives the direction, so a standing body still turns to the last heading asked for (on the spot, throughspins, when it owes more thantuning.spinMin).- State it from
updateBefore()of an aspect ordered before the Locomotion (static before = [Locomotion]), which reads it in its ownupdateBefore(). facegives a facing of its own (aiming, a camera-relative shooter); withstrafe: truethe body moves alongdirectionwhile it faces there, through thestrafesblend (state'strafe').
class AimInput extends Aspect<'aimInput', Node> {
static before = [Locomotion]
aim = new Vec3(0, 0, 1) // the world direction the gun points — set by the aiming code
updateBefore(): void {
const x = (Input.key('KeyA') ? 1 : 0) - (Input.key('KeyD') ? 1 : 0)
const z = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
this.node.get(Locomotion)!.move(x || z ? [x, 0, z] : null, { face: this.aim, strafe: true })
}
}Rolls, dodges and attacks that move the character: loco.act {#act}
A roll, a dodge or an attack whose animation carries the character is an ACTION:
loco.act(clip, { direction?, angle?, turn?, at?, exit?, fade?, out?, rate?, travel?, dash? }): booleanact(clip, …) plays an ACTION — a roll, a backstep, a lunge — that owns the body the way a start or
a stop does, then hands back to the usual rules; false = no such clip (or no locomotion engine on
the host). direction (world: a Vec3 or a heading in degrees; left out = where the body faces) is
where its travel must go: the body is turned by the difference between that and the way the clip
travels (angle, degrees, + = left; measured off the clip's root when left out) over turn seconds
(default 0.12). at enters it that many seconds in; exit is the clip time from which a move intent
may take over (the cancel window); fade / out are the transitions in and out (default
tuning.blend); rate its playback rate; travel the share of its recorded travel to take (or
{ share, at, after }); dash: { distance, time? } throws the body distance metres along the
action's direction within time seconds (default 0.12). state reads 'action' meanwhile. When an
action may cut in (out of another one, out of a start) is the game's rule: act cuts into whatever
shows.
const dodgeOnSpace = (player: Node) => {
Input.on('keydown', (e) => {
if (e.code !== 'Space' || e.repeat) return
player.get(Locomotion)!.act('Roll', { exit: 0.5, dash: { distance: 1.5 } }) // a move may cut in from 0.5 s
})
}Read the speed, state and path ahead: loco.state, loco.speed {#what-came-of-it}
loco.state // 'idle' | 'start' | 'move' | 'turn' | 'stop' | 'spin' | 'action' | 'strafe'
loco.on('state', (state) => …) // what the selector shows changed
loco.speed // m/s the body travels at — the simulation's
loco.velocity // Vec3, world m/s
loco.facing // degrees, the node's yaw (0 = +Z, + = toward +X)
loco.phase // 0–1 through the gait cycle (-1 = none)
loco.trajectory // Vec3[] — where the body will be in 0.2 / 0.4 / 0.7 s if the intent holds
loco.yawLeft / loco.clipTime / loco.exitAt // the shown transition (debug)
loco.stopFootOffset / loco.stopStepsLeft / loco.stopStepWait / loco.stopReport() // why a stop plays the way it doesThe simulated speed approaches the wanted one as a spring: tuning.responseWalk (default 0.27 s) is
the halflife of the difference — half of it is gone after 0.27 s. The first frame emits the state it
starts in ('idle').
Acceleration, turning and braking: LocomotionTuning {#tuning}
LocomotionTuning — times in seconds, speeds m/s, angles degrees; every key optional.
| Key | Default | Meaning |
|---|---|---|
speeds |
the clips' own | Top speed per gait, { walk, run, sprint }. Left out, each gait travels at the speed its clip was recorded at — which keeps the feet from sliding |
responseWalk, responseRun |
0.27, 0.2 |
Halflife of the gap between the simulated and the wanted speed |
responseStop |
0.15 |
How briskly the body brakes when nothing is asked for |
responseFacing |
0.15 |
The same for the facing |
turnRate |
uncapped | Cap on turning by STEERING, °/s. A start / turn / spin clip owns the facing while it turns |
turnMin |
60 |
Heading error that plays a turn clip instead of steering through it |
spinMin |
30 |
Facing a standing body must owe before it turns on the spot |
blend, stopBlend |
0.2, 0.3 |
Transition seconds between clips / into the idle after a stop |
yawWarp |
0.3 |
How far a start / turn / spin clip's turn may be scaled to deliver the heading asked (0.3 = ×0.7 … ×1.3; 0 = the recording is law) |
exitTolerance |
0.15 |
A start or turn hands over to the gait loop at the first foot-down where its pose is within this many metres of the loop's (0 = at its end) |
exitSteps |
0 |
…then plays this many more foot-downs first |
resumeSpeed |
0.5 |
A stop asked to move again resumes the loop while the body still has this fraction of the gait's speed |
stopEarly, stopSteps, stopFit, stopPose |
0, 0, 0.25, 1 |
How a stop variant is chosen: braking room traded for a longer lead-in / an extra step, the pose distance allowed before a take's first foot-down, a metre of pose against a metre of room |
strideShare, strideBand |
0.5, [0.85, 1.15] |
A gait asked at a speed not its clip's: the stride takes ratio^share (within the band), the cadence the rest |
warpStride, warpCadence |
automatic | Pin the stride / cadence ratio (a tuning bench) |
deadZone |
0.2 |
Stick magnitude below which it counts as released |
adjust |
0.5 |
'hybrid': how far, m/s, the body may be pushed off the recording toward the simulation while the loop shows |
Stop the feet sliding: stride and orientation warping, anim.warp {#the-warp--animwarp}
The feet slide when the character moves faster or slower than its animation, or in another
direction. anim.warp — the Warp stage of every Animator, under a Locomotion or on its own — fits
the pose to the body's real motion: the stride to its speed, the lower body to the way it travels.
anim.warp.set(true) // stride + orientation, the defaults
anim.warp.set({ stride: [0.7, 1.4], orientation: 45 }) // REPLACES: what is not asked for is off
anim.warp.orientation = 12 // properties change one setting
anim.warp.options // what is on
anim.warp.step = { slope: 15 } // the step warp dials; null = off
anim.motion = controller.velocity // the body's world velocity (m/s) when no Locomotion feeds itWarpOptions |
Default | Meaning |
|---|---|---|
stride |
off | Fit the stride to the speed the body really travels at; [min, max] clamps the scale (true = 0.85 … 1.2) |
orientation |
off | Turn the lower body toward where the body really travels, the spine counter-turning; a number caps it in degrees (true = 20). Applied while the gait loop shows |
minSpeed |
0.2 |
Below this speed (the body's or the clip's), m/s, both warps are off |
pelvis |
0.25 |
How far, m, the pelvis may drop to keep a stretched leg from locking straight |
strideTime |
0.15 |
Seconds of smoothing on the stride scale |
StepWarpOptions (anim.warp.step): stride scales each foot's step, lift adds metres to the
swinging foot's height (negative = a shuffle; half of it raises the pelvis), pitch rotates each foot
(degrees, + = toes up), slope (degrees, + = ascending) puts the feet on an incline and pitches them
with it — raise the character by tan(slope) × the clip's travel to keep each planted foot on its tread.
Both warps need two motions: the body's (a Locomotion feeds it every frame; drive the gait by hand
and set anim.motion every frame instead) and the clip's (its root motion, or for an in-place loop
the pace its pack declared in the GLB). A pack whose takes already read right at the speeds they are
played at wants none of this.
See also
- Animator — clips, blends,
anim.clipInfo()(what the engine measured) - IK and feet — the foot lock and ground IK a Locomotion turns on
- Physics & shapes —
CharacterController - Aspects —
updateBefore,static before/after