Skip to content

MOVEMENT.md — how characters move, and why

kke::Locomotion (engine/include/kke/Locomotion.h) is the movement layer between input actions and RigidWorld's character controller. Its rules come from PointDown's controller, "mechanics museum" and parkour videos (https://www.youtube.com/@PointDown). They're Godot videos, but the principles are engine-independent. This file keeps the notes and says where each principle lives in the code.

Tested in tests/test_locomotion.cpp (12 tests, including the whole kke_demo parkour lane at 60 and at 15 fps). You can play it in kke_demo: walk to the lane at x = 20, or run KKE_DEMO_AUTOPILOT=1 ./kke_demo to watch it run the lane by itself. The Synty scenes (SCENES.md) are real-art trails: SceneTrails.* runs them headless when the packs are present.

The frame

PointDown's controller core (Advanced controller core, E3BxMgmP4m0) splits a character into input → model → presentation:

  1. Input: InputModule / InputMap gather actions (move, sprint, walk, crouch, jump). The game never reads keys.
  2. Area awareness runs next, before any state logic, and is the only code that asks the world questions (Locomotion::probe).
  3. Translation, per state: the current state turns actions into a move. jump on the ground becomes a vault if a thin, hip-high obstacle is ahead, a climb if there's a top with room, and a jump otherwise (Designing AAA parkour system, veOpS2S8Lco: "input actions are not move names").
  4. Update: the state moves the capsule.
  5. Presentation: the Animator picks poses from the state. Animations are pose providers: they never decide where the capsule goes (Root motion once and for all, _PHRm2EqfX0).

Area awareness: dynamic, no markup

PointDown compares static awareness (trigger volumes placed by a designer) with dynamic awareness (ray casts that read the level). Dynamic costs more code but lets a level designer drop in any mesh (Designing AAA parkour system; Ledge actions, IMSFMmekFxg). We use dynamic awareness because the Synty levels have hundreds of props nobody will mark up. probe() costs about 15–30 ray casts and 2–4 capsule tests, and runs only on the frame "go up" is pressed, or every frame while in the air.

  • Face: rays forward at knee, hip and chest height, from the middle and both shoulders (a centre ray slips through the seam between two fence panels). Anything lower is a stair the controller steps up by itself.
  • Top: one ray down, just past the face, from above the highest top this sensor can reach, and a second one further in when the rim is bevelled (rocks). A ray that starts inside geometry means the obstacle is too tall.
  • Depth: rays down across the top until it drops away. Thin is a fence (vault); a platform is a climb.
  • Room: capsule tests for a tucked body over the top, and for a standing body at the landing or on the top.
  • Sensors per state (PointDown's "ray slice" resources): walking looks 0.7 m ahead and reaches 1.9 m, sprinting looks 1.6 m ahead and reaches 2.2 m, and the air sensor reaches 2.1 m. A 2.1 m ledge is only climbable from a sprint.

Floor: three tiers, not two

From Designing AAA parkour system: on the floor; up to 25 cm above the floor, which still counts as walking (snap down, no fall animation on a kerb or a stair edge going down); and in the air. That removes the fall/land stutter on small drops. Coyote time (0.12 s) and a jump buffer (0.15 s, PointDown's "queued input") come from the same idea: don't make the player hit a one-frame window.

Drawing it: between physics steps

The capsule moves at the physics rate (60 Hz). Drawn at its raw position on a faster screen it stands still one frame and jumps the next, and a camera that follows it smoothly (the spring arm's lag) shows that as a shaking body, worst while the camera turns. Games draw the character, and point the camera, at RigidWorld::characterDrawPosition(id, app.fixedAlpha()): the feet between the last two physics steps. Gameplay (Locomotion, hits, the network) keeps using characterPosition. The showcase, the starter game, the cookbook, Climb Race, the party game, tennis, the duel, the goblin horde and the pet do this.

Turning (Smoother turn movement, MM1, ysKxT3q4tA8)

  • Speed and direction are separate. Input rotates the direction at a limited rate (600°/s running, 320°/s sprinting) instead of replacing the velocity. The head doesn't jump 40 cm in one frame when you mash A/D.
  • Sharp turns slow you down to 60% while the turn lasts ("creatures that value their lives drop speed in turns").
  • A 180° reversal keeps its side. Near 180° the sign of the angle flips on float noise, and the turn would change sides every frame; the last turn direction wins inside ±12° of 180°.
  • The animation follows the measured speed: the blend space gets how far the feet actually moved, not the requested speed. Legs slow down in turns and stop against walls. It is measured over the time physics simulated since the last frame (physics steps at 60 Hz, frames don't), so a frame between two physics steps doesn't read as standing still (BUG-054).

Jumping and the air (Jump & Fall control, MM2, vPFAh2T8Ipg)

  • Air control is a small acceleration (5 m/s²), added to the flight and capped at the take-off speed (at least 1.6 m/s for standing jumps). It corrects a jump; it doesn't fly the character.
  • Facing is stored separately from velocity. When a wall deflects the flight, the body still faces where you meant to go.
  • Take-off is a glue window. The last grounded frame may re-aim the run at the input, so a jump straight after a turn goes where you asked.
  • A jump always happens. The press is kept until a physics step takes it (RigidWorld::CharacterInput::jump): on a screen faster than the 60 Hz physics most frames have no step, and the next frame's input used to wipe the jump while the jump animation still played. A coyote jump (or one from the kerb tier) jumps even when the capsule isn't touching the ground that step (jumpInAir): Locomotion has already decided.
  • No bunny hopping. Jumping again within 0.3 s of landing a jump goes up at 82% of the last one's speed (67% of the height), down to 60% of a full jump (repeatJumpWindow, repeatJumpFactor, repeatJumpMin); a moment on the ground gives a full jump again. Mashing the button can't keep the character floating, and the jump animation plays only when a jump really starts (Locomotion::jumped()). Tests: Locomotion.JumpSurvivesFramesWithoutAPhysicsStep, CoyoteJumpGoesUp, RepeatJumpsGoLessHigh.
  • Ledge grab in the air: rising slowly or falling, steering into a wall whose top is within reach turns into a climb when "go up" is held, or when the top is less than 1.5 m above the feet. Otherwise it hangs.

Ledge hang and shimmy (Ledge actions, IMSFMmekFxg)

  • Grab: in the air, steering into a wall whose top is 1.5-2.35 m above the feet (and "go up" not held) hangs from it. That includes edges the probe can't stand on, like a thin wall's top. The capsule is pulled in over 0.15 s to 2.05 m below the top, square to the wall.
  • Shimmy: the sideways part of the input moves along the edge at 1.1 m/s. Every step re-finds the wall (at chest height) and the top (just in from the face). Where either is gone, the top steps by more than 15 cm, or the capsule wouldn't fit, the character stops: it never shimmies off the end or through a side wall. The wall normal is re-read each step, so gently curved walls work.
  • Corners: where the edge ends, an outside corner (the wall turns away) or an inside corner (a wall ahead) with a top at the same height is followed: the character moves around it over 0.3 s and hangs from the new face. The held input keeps going the same way along the ledge, even though it no longer points along the new wall. A top that steps up or down is a real end.
  • Jump back: "go up" while pushing away from the wall jumps off it (3.5 m/s out, 80% of a jump up), facing away, with no re-grab for 0.4 s.
  • Climb up: "go up" runs the normal checked climb from the hang (so a thin wall with no room on top can be hung from but not climbed).
  • Let go: crouch. A small push off the wall, no coyote jump, and no re-grab for 0.4 s.
  • Animation: no hang clip in the UAL sets, so kke_demo uses the fall pose slowed down with hand IK on the edge; a pack with Hang_Idle is picked up by name. KKE_DEMO_HANG=1 ./kke_demo runs a jump, hang, shimmy around the wall's end and a jump back at the lane's 3 m wall by itself.
  • Tests: Locomotion.JumpAtAHighWallHangsFromTheTop, ShimmyAlongTheEdgeStopsWhereItEnds, ClimbUpFromAHang, CrouchLetsGoAndDoesNotRegrab, HangsFromAThinWallItCannotStandOn, ShimmyAlongTheEdgeAndAroundTheCorner, ShimmyStopsWhereTheTopStepsUp, ShimmyIntoAnInsideCorner, JumpBackOffAHang.

Ledge leaps (Ledge actions, IMSFMmekFxg)

  • Sideways: hanging, "go up" with the stick pushed along the wall leaps to the next edge that way. The search walks along the wall past the end of the current edge (the part shimmying covers is skipped) and takes the nearest edge within 2.2 m that is up to 1.3 m higher or 1 m lower. It lands a body width in from that edge's end, so the hands aren't on a corner.
  • Up: with the stick into the wall (or no stick), "go up" climbs if there is room on top. If there isn't (a thin wall, a sill), it leaps up to an edge above on the same wall, up to 1.3 m higher.
  • The target is found and checked before the leap, including room for the body there and at the middle of the arc. The body moves kinematically for 0.45 s: evenly along the ground, on a parabola in height that peaks about 0.3 m above the higher end (PointDown's ledge-leap parabola). It hangs on arrival. Hand IK reaches for the new edge in the last 40%.
  • No target (nothing in reach, no room) means no leap. A sideways "go up" then climbs if it can, as before.
  • Tests: Locomotion.LeapsSidewaysAcrossAGapToAHigherEdge, LeapsUpToAnEdgeAbove.

Wall climb (run up a wall)

  • Start: on the ground, running into a wall at 2.5 m/s or more, "go up" with the wall at most 1.2 m past the capsule, square to it (within about 45 degrees), and its top too high to climb straight (above the sprint sensor's 2.2 m) but no higher than 4.3 m above the feet. There must be a hangable edge at the top and room for the body all the way up.
  • The move: a Leap (wallClimbing() is true) that goes to the wall's foot in the first third and rises the whole way, slowing as it goes (the run-up's momentum running out), 0.6 s to the hang position. It hangs on arrival, and "go up" climbs on from there.
  • Animation in kke_demo: the start of UAL2's ClimbUp_2m (the spring and the reach), with foot IK planting two steps on the wall, left then right, each held where it first touched while the body rises past it.
  • Standing still, "go up" at the same wall is a plain jump.
  • Tests: Locomotion.RunningAtATallWallRunsUpItAndHangs, StandingAtATallWallIsNoWallClimb.

Hang vault (over a thin wall from a hang)

  • Hanging from a wall too thin to stand on (the probe finds no room on top), "go up" looks across the top for where it drops away (at most 1.1 m), for a floor on the far side (at most 4 m below the top), and for room for the tucked body over the top and the standing body at the landing. Then it vaults over: a Vault (hangVaulting() is true).
  • Path: pull up at the wall until the feet are level with the top (the first 45% of 1 s), then across with the feet never below the top until past its far side, then down to the floor, falling faster as it goes. It ends on the ground with a little of the run (40% of run speed).
  • An edge above to leap up to comes first (the trick course's beam over its thin wall); the hang vault is for a thin wall with nothing above.
  • Animation in kke_demo: ClimbUp_2m's pull-up, then the second half of SafetyVault; hand IK keeps the hands on the top until the body is over.
  • Test: Locomotion.VaultsOverAThinWallFromAHang.

Wall run

  • Start: in the air, going at least 4 m/s, still holding the run, with a vertical wall at the hips and head within 0.45 m of the capsule's side, and running along it rather than into it (the wall is within 60 degrees of parallel). At least 0.35 m off the ground, so a wall beside a kerb doesn't count.
  • On the wall: the body moves kinematically along the wall at the entry speed, with the wall's normal re-read each step (gently curved walls work). Height follows a lighter gravity (7 m/s² instead of 9.8), starting with 70% of the take-off's vertical speed, so a run arcs up about a metre and comes down. It lasts at most 1.1 s.
  • Ends: "go up" is a wall jump (4.5 m/s off the wall, 85% of a jump up, 80% of the run kept). Letting go of the stick or crouching drops off. Where the wall ends, the runner flies on with the momentum. Something in the way stops the run. Landing on the floor keeps the run going on the ground. The same wall can't catch you again for 0.35 s, but another wall can, so wall-to-wall chains work.
  • Animation: UAL2's WallRun_L_Loop / WallRun_R_Loop by wall side (Locomotion::wallRunSide()). The camera's shoulder moves to the open side during the run, because over the wall shoulder the spring arm would pull in behind the head.
  • Tests: Locomotion.JumpingAlongAWallRunsOnIt, WallJumpKicksOffTheWall, SlowJumpNextToAWallIsNoWallRun.
  • kke_demo: the trick course at x = 26 has a 12 m wall to run along, a row of thin pillars with 1 m gaps and rising tops for leaps, and a thin wall under a beam for the leap up. KKE_DEMO_TRICKS=1 ./kke_demo plays through all of them.

Vault and climb (Parkour ep3, rhzwhJPb-jQ; Ledge actions)

  • The capsule becomes kinematic for the move (RigidWorld:: setCharacterKinematic). It follows a path that was checked for room before the move started, so there's no fight with the solver halfway over.
  • Correction window (first 0.2 s): the body turns square to the obstacle, whatever angle you came in at.
  • Vault path: PointDown's ledge-leap parabola (leapParabola). It is solved through the start and the landing, with a peak that clears both edges of the top. Horizontal speed stays constant, so a speed vault keeps its momentum (90% of the entry speed).
  • Climb path: up the wall to the edge (hands on top), then over it onto the top.
  • At the end the momentum goes back to the controller, and the character is on the floor that the probe found (no "stand on thin air" snap).

Free climbing (kke::ClimbWall, kke::Climber; games/climb_race)

Ledge hang is for edges you run into; free climbing is a whole rock face where you choose every hold.

  • The wall (ClimbWall::generate(seed)) is a height field over x, y: bands of lean (an easy start, a forced overhang in the middle, a slab at the top), fbm relief and buttresses. Ledges are boxes sticking out of the rock. Holds (jug, crimp, sloper, edge) are placed along a drawn line that is always climbable, then filled in around it. Same seed, same mountain.
  • Reach is ClimbWall::reachDistance: sideways and up count fully, in and out of the rock a quarter. Routes, the climber and the bot all use it.
  • The climber (kke::Climber) is pure logic, with no physics. The capsule is kinematic at feet() while climbing and goes back to kke::Locomotion on a fall or at the top. The triggers move the hands (1.55 m reach), the bumpers step the feet onto footholds, and jump held then let go lunges the body up to 1.35 m (grab in time or fall). The body hangs under the hands and stays out of the rock. Stamina is the one resource: it comes back only with both feet on, and the arms alone cost double.
  • The body is IK only: the hang clip slowed down, with two-bone IK putting each hand and foot exactly where Climber says.
  • Tests: tests/test_climb_wall.cpp covers 25 seeds climbable, reaches, lunges, feet, loose holds, stamina, no clipping and the bot to the top.

Animations

The Universal Animation Library "Standard" set in assets/animations/ has no vault or climb clips (its 43 clips are locomotion, jump, crouch, combat, sitting, swimming and interaction). Volume 2 (UAL2.fbx, CC0, same skeleton, git-ignored like the first) has them: put it next to UAL1_Standard.fbx and kke_demo plays

  • SafetyVault for vaults (one hand on the top, legs swung to the side),
  • ClimbUp_1m for climbs up walls under 1.6 m, ClimbUp_2m above that.

Those clips are made in place: the pelvis rises up to 0.8 m and comes back down at the end. Locomotion already moves the capsule up and over, so AnimationSet::removeLift takes the vertical travel out of them (the forward and sideways body motion stays). And they aren't played by the clock: Animator::setProgress poses them at Locomotion's progress through the move, so a vault that takes 0.4 s or 0.8 s (it depends on the entry speed) still puts the hand down on the edge. Other players see the same clips: the network state carries the obstacle height during the move.

Without volume 2 the stand-ins are used: the tucked jump pose for the vault, the take-off reach and a crouch step for the climb. There is no hang or shimmy clip in either volume (the hang uses the slowed fall pose). kke_demo makes the shimmy with IK: hand over hand along the edge, each hand planted while the body moves past it (60% of a 0.36 m stride), then lifted a few centimetres and moved on, the leading hand first; the feet step along the wall below in between.

After the Animator, two small modifiers run in a fixed order (engine/include/kke/AnimRig.h, PointDown's SkeletonModifier3D idea):

  • Feet on the ground. Each foot keeps its animated lift above the ground under it, the hips drop when one foot has to go lower than the capsule's floor (stairs, slopes, rock), and analytic two-bone IK bends the legs. Each foot also tilts to lie along the ground under it (the ray's normal, at most 30 degrees), keeping the animated foot angle on top of that. Smoothed over frames, and off in the air.
  • Hands on the edge. During the first part of a vault or climb, the hands go to the top edge the probe found, shoulder-width apart, with the elbows bending out and back. It stands in for the hand plant the stand-in clips don't have.

Any Synty character can wear the UAL clips. Bones pair up by name (UAL and Synty both follow the Unreal mannequin, give or take case and Synty's indexFinger/finger), and each paired bone copies the source's rotation change from its rest pose, turned by the difference in facing (UAL faces -Z, Synty +Z); the pelvis travel scales with leg length. The match logs which bones stayed at rest (Synty's eyes, eyebrows and toes). Importing animated 3D characters (a0_JVEY7sbY) puts it as a contract: a clip only means something for the skeleton it was made for, so a mismatch should be visible, not silent. Try it in kke_demo's Character panel, or KKE_CHARACTER=SK_Character_Father_01 ./kke_demo.

2D blend spaces (ENG-G19) blend clips placed over two parameters, usually the velocity in the character's own frame (x right, y forward, m/s): Animator::addBlendState2D with a BlendSpace2D, then setParameter2 every frame. Every clip gets a weight by gradient band interpolation (Johansen 2009, the method behind Unity's freeform blend trees) instead of a triangulated grid (Unreal), so any layout works and the weights change smoothly; in the default polar mode direction is an angle and speed a length, so a full-speed diagonal blends forward and right only, never idle. All weighted clips run at one shared phase, so feet don't slide. goblin_horde's heroes strafe with one: walking round a circle the weights change by at most 0.006 per half degree, where the old left/right/back thresholds jumped from one clip to the next; it costs 3.6 µs an update for a 65-bone hero against 2.3 µs for a 1D space.

Root motion is available as data: AnimationSet::extractRootMotion moves a bone's horizontal travel out of the clips into a track, the clip plays in place, and Animator::rootMotion() reports the travel each update for the game to apply. Locomotion clips don't use it (the controller moves the capsule, and the blend space follows the measured speed); it's for authored moves such as a real vault clip.

Animation events (ENG-F28, Unity's animation events, Unreal's notifies): a name at a point in a clip, reported on the update that passes it, for footstep sounds, the frame a sword hits or a ball leaves the hand. Animator::addEvent(state, fraction, name) places one at a 0..1 fraction of a state's cycle, so it also works on blend spaces (a step at 0.25 and 0.75 lands with the feet at any speed); Animator::events() lists what the last update() passed, current state only (one fading out says nothing), once per pass (a loop every cycle), events at 0 on the first update after play(). For plain clips on an instance, ModelModule::addClipEvent(model, clip, seconds, name) and clipEvents() do the same in seconds; Lua has them as models.event and the AnimEvent hook (SCRIPTING.md). The window maths (kke/AnimEvents.h) is shared and unit-tested.

local hero = models.load("Hero")
models.event(hero, "Walk_Loop", 0.3, "step")
models.event(hero, "Walk_Loop", 0.8, "step")
hook.Add("AnimEvent", "hero.steps", function(e)
    if e.name == "step" then audio.impact(heroPos, "Stone", 0.3) end
end)

Retargeting between rest poses

kke::retargetAnimations and kke::PoseRetarget copy each bone's turn away from its rest pose. Two skeletons modelled in different rest poses (an A-pose character playing T-pose clips) would keep that difference in every frame, so rest matching (on by default, ENG-G19) first turns each arm and leg bone of the target to point the way the source's points at rest. Hands turn with the forearm, feet stay flat on the floor and the spine keeps its curve: those differences are the body's shape, not its pose. Pass matchRest = false to keep the old copy.

Measured with UAL's walk on one character from each of 26 installed Synty packs (AnimRig.RestMatchingOnInstalledCharacters): the worst limb pointed 5 to 6.2 degrees away from the clip without matching (the werewolf's arms rest 30 degrees off UAL's), 0.0 with it, and no foot moved. PoseRetarget::restDifferenceDegrees() says how much was matched.

Not done yet (next)

  • Animation layering (upper body over locomotion; Animation layering pipelines, Fsa2wxyQvzM, blocked below).

Transcripts

Fetched 2026-09-26 as auto-captions. YouTube blocks this server after about a dozen requests, so these videos didn't come through: God Tier 3D Character Controller (qIf5YQ8qJng), Detect climbable ledges (yxWxHfjNpa4), Animation layering pipelines (Fsa2wxyQvzM), Post-start melee attack redirection (WGZ-QG-0cpw), Use professional AAA practices (FgO5edghqRE). Importing animated 3D characters (a0_JVEY7sbY) came through on a retry. Their principles overlap with the videos above; retrying from another network would complete the set.