Skip to content
All devlog entries

A Character Controller Is Two Problems

physicscharacter controllerarchitecture

“Character controller” sounds like one feature. Spawn a capsule, move it when the player pushes the stick. Then you try to make it feel good, and two separate problems show up: moving a capsule through geometry without lying about what it hit, and turning player intent into velocity that feels right. They have almost nothing to do with each other, so Eresh doesn’t mix them.

A primitive and a motor

The bottom layer is a kinematic capsule. Kinematic means physics never pushes it around: code moves it, the world answers questions. You hand it a desired translation, it sweeps the capsule through the scene and reports back the truth: where it ended up, and every contact it met, each one already classified as ground, wall, step or ceiling. It knows about slope limits, step heights and ground snapping. It does not know what gravity is.

The top layer, CharacterMotor, is the movement model. It’s just knobs:

pub struct CharacterMotor {
    pub max_walk_speed: f32,      // 4.5 m/s
    pub ground_acceleration: f32, // 45 m/s², responsive without being frictionless
    pub ground_braking: f32,      // 60: releasing the stick stops faster than pushing it starts
    pub air_control: f32,         // 0.35: enough to correct a jump, not to turn it around
    pub gravity: f32,             // 20, not 9.81: real gravity makes jumps feel floaty
    pub jump_height: f32,         // honest metres, converted to a take-off velocity via sqrt(2gh)
    pub coyote_time: f32,         // 0.12 s, roughly seven frames at 60 Hz
    pub jump_buffer_time: f32,    // 0.15 s: a mistimed press becomes a jump on touchdown
    // ...sprint, crouch, terminal fall speed, turn rate
}

The two layers talk through one small component, MotionInput: a move direction, a facing direction, three booleans. Whoever fills it in, the same code runs. The action bridge fills it for the player, a Luau script fills it, an AI fills it. An NPC and the player share every line of movement code, which means possession is a component swap, not a rewrite.

The motor is optional on purpose. Drop it and you’re holding the bare primitive, which is the right tool for air-strafing, grapples and dashes: movement so bespoke that an engine’s idea of “walk” would only get in the way. Everyone else adds five components and ships.

The three things rapier didn’t do

The primitive wraps rapier 0.33’s KinematicCharacterController, and on paper that type does everything. In practice it has three gaps, and any one of them silently breaks the character:

  1. Depenetration is an empty stub upstream. A capsule that starts a tick even slightly embedded in the floor finds no contact and falls straight through it.
  2. The generic contact query’s tolerance scales with shape size. Against a large floor it can return normals tens of degrees off vertical, which on screen reads as a character slowly drifting sideways while standing still.
  3. Ground snap and depenetration disagree about the axis. rapier’s snap is strictly vertical (translation -= up * toi), so pushing the capsule out of the floor along the contact normal leaves a sideways residue every tick: a character standing still on a 30 degree ramp creeps downhill at about 0.3 m/s.

None of this crashes. You just get a character that sinks, drifts or slides, and no error message tells you why.

The fixes live in the wrapper. Depenetration now runs a manifold-based pass first, targeting one and a half times the skin width: land exactly on the sweep’s own contact distance and you get a two-state oscillation that quietly eats about 14% of walk speed. That pass probes with contact manifolds rather than parry’s generic contact query, so the floor normal is exact instead of tolerance-dependent. The ground lift happens along up, not along the normal. And the sweep is split in two, planar then vertical, because casting the whole desired translation as a single move lets a floor contact spend the horizontal budget.

The knobs are the feature

Once the contacts are honest, most of the remaining work is choosing defaults. Gravity is 20 rather than 9.81 because real gravity makes jumps feel floaty and every shipped platformer overshoots it. Braking is higher than acceleration, because stopping should beat starting. Jump height is authored in metres and converted with sqrt(2gh), so the number you type is the jump you see. Coyote time is applied to the jump budget rather than to the jump test, which is the only way it doesn’t contradict double jump.

One rule from the integration side: velocity is reconciled from the contact normals, never read back from the motion the solver applied. A character held into a wall must not bank speed and launch the moment it reaches a corner.

Where it stands

Working today: slopes, steps, ground snap, ceilings, wall slide, and pushing dynamic crates on the very tick you touch them, because the move runs inside the physics window rather than after it. Animation root motion feeds the same sweep, so a walk cycle that walks into a wall stops. Crowds batch: from twelve characters up the sweeps resolve in parallel across the thread pool (single-threaded on the web build, and the output is identical either way), and a test pins that a crowd moves exactly like characters moved one at a time.

In the editor, every knob above appears in the inspector without a bespoke widget: a reflected component gets its editor for free, undo included. What did need writing is the part reflection can’t guess: a debug overlay that draws the ground probe, the slope-limit cone, the step-offset ring, the live ground normal and the velocity vector; a lint that catches a broken recipe before you press play; and an inspector banner that names the broken component and repairs it in one click.

The honest gaps: mouse-look works, but the pointer is never locked. The engine’s cursor-grab path is editor-only, so the raw device deltas are discarded and the look runs on plain window cursor motion, which can walk off the window mid-turn. Crouch slows you but doesn’t shrink the capsule yet, since standing back up needs an overlap test first. There is no sleep policy, so a crowd of idle NPCs still sweeps every physics substep. Eresh is pre-alpha, and these are on the list rather than in the bin.

What’s next

The idle-crowd problem is the interesting one: skipping sweeps for characters that have been standing still for a long time is the biggest remaining win, but a sleeping NPC stops publishing state, so it’s a gameplay decision more than an optimization. If you want to watch a capsule become a character yourself, the editor runs in the browser with no install required.