Character Controller
A character is not a rigid body. CharacterControllerBehavior sweeps a capsule through the scene on every fixed step: the character asks what it is standing on, works out the velocity it wants, and the sweep stops it where it should stop. Movement belongs to the game, and geometry answers whether the move it wants is allowed.
The behavior wraps Babylon's PhysicsCharacterController, which builds a transform node of its own and an ANIMATED body on it. Nothing pushes that body, and it does not fall unless the behavior gives it gravity. It is in the scene like any other animated body, so a character walking into a crate moves the crate.
What a character needs
CharacterControllerBehavior declares NodeEntity as its entity type, so it composes onto a NodeEntity class and onto nothing else.
import { CharacterControllerBehavior, defineEntity } from '@skewedaspect/sage';
export const PlayerEntity = defineEntity({
type: 'PlayerEntity',
behaviors: [ CharacterControllerBehavior ] as const,
mesh: { source: 'capsule', params: { height: 1.8, radius: 0.3 } },
});The scene needs physics, for the reason a body does: Babylon's controller reaches the Havok plugin on every call. A character spawned into a scene with no physics engine throws, naming the entity. See Physics.
CharacterControllerBehavior is not a variation on HavokPhysicsBodyBehavior, and a character needs no rigid body of its own. The capsule is swept rather than simulated, and it sits on the node the controller made. The entity's own node carries no physicsBody at all, and per-entity collider visualization walks that node, so showCollider() draws nothing for a character.
The behavior does not read input
The behavior owns movement and not input. It reads moveX, moveZ, wantsJump, wantsSprint, and wantsStance from its own state, and a game writes those from whatever its bindings are. Nothing in the behavior knows what a key is, so a gamepad, an on-screen stick, and a recorded demo all drive the same movement code.
A game writes those fields from a behavior of its own, which declares the same fields and sits on the same entity:
import { Behavior } from '@skewedaspect/sage';
import type { CharacterControllerState, GameEvent } from '@skewedaspect/sage';
type WalkState = Pick<CharacterControllerState, 'moveX' | 'moveZ' | 'wantsJump'>;
export class WalkInputBehavior extends Behavior<WalkState>
{
static readonly events = [ 'action:moveForward', 'action:moveRight', 'action:jump' ] as const;
readonly defaults : WalkState = { moveX: 0, moveZ: 0, wantsJump: false };
onEvent(event : GameEvent) : boolean
{
if(event.type === 'action:moveForward')
{
this.state.moveZ = event.payload;
}
else if(event.type === 'action:moveRight')
{
this.state.moveX = event.payload;
}
else if(event.type === 'action:jump')
{
this.state.wantsJump = event.payload;
}
return false;
}
}moveX and moveZ are in the character's own frame: moveZ of 1 is forward along the node's facing and moveX of 1 is to its right. Camera-relative movement is a game's to work out, by turning the node or by converting before it writes.
State
| Field | What it is | Default |
|---|---|---|
moveX | how hard a game is asking to move right, -1 to 1 | 0 |
moveZ | how hard a game is asking to move forward, -1 to 1 | 0 |
wantsJump | a jump has been asked for | false |
wantsSprint | sprint is being held | false |
wantsStance | the stance a game is asking for, by name | 'standing' |
stances | what each stance's capsule and speed are | standing and crouching, below |
measureCapsule | take the tallest stance's capsule from the mesh instead | false |
sprintScale | what sprinting multiplies the current stance's speed by | 2 |
jumpSpeed | upward metres per second a jump starts with | 5 |
jumpBufferSteps | how long a jump request survives, in fixed steps | 6 |
coyoteSteps | how long after leaving the ground a jump is still allowed, in fixed steps | 6 |
velocity | metres per second | zero |
position | where the character is | NodeEntity's own, the origin |
grounded | whether the last step's support query answered SUPPORTED | false before the first step |
stance | the stance the character is actually in | 'standing' before the first step |
Every number is a default a game replaces with its own.
position is the field NodeEntity already declares, and it is the character's foot rather than the middle of its capsule. Babylon's controller works in capsule centres and the behavior converts, so a spawn point is a place someone stands and a character that changes stance does not move.
There is no gravity field. A character falls under the physics engine's gravity, the same as everything around it; a game that wants a jetpack overrides desiredVelocity.
Default stances
Each entry in stances is a StanceConfig, exported beside the behavior: a height and a radius in metres, and a speed in metres per second. A game that keeps its stances outside the entity definition declares them with that type.
import type { StanceConfig } from '@skewedaspect/sage';
export const stances : Record<string, StanceConfig> = {
standing: { height: 1.8, radius: 0.3, speed: 3 },
crouching: { height: 0.9, radius: 0.3, speed: 1.5 },
};| Stance | Height | Radius | Speed |
|---|---|---|---|
standing | 1.8 | 0.3 | 3 |
crouching | 0.9 | 0.3 | 1.5 |
A game replaces the record rather than editing it, and adds as many stances as it has:
import { CharacterControllerBehavior, defineEntity } from '@skewedaspect/sage';
export const CrawlerEntity = defineEntity({
type: 'CrawlerEntity',
defaultState: {
stances: {
standing: { height: 1.8, radius: 0.3, speed: 3 },
crouching: { height: 0.9, radius: 0.3, speed: 1.5 },
prone: { height: 0.5, radius: 0.22, speed: 0.6 },
},
},
behaviors: [ CharacterControllerBehavior ] as const,
mesh: { source: 'capsule', params: { height: 1.8, radius: 0.3 } },
});The speed a character walks at is its current stance's, multiplied by sprintScale while wantsSprint is held. On the defaults that is 3 metres per second standing, 6 sprinting, 1.5 crouched, and 3 crouched and sprinting.
grounded and stance are answers
Both are computed from what the last step worked out (Entities). grounded is true only when the support query answered SUPPORTED, and stance names an entry in stances.
Sliding is not standing. Babylon's support query has three answers, and a character on a face too steep to hold is SLIDING rather than UNSUPPORTED. It is touching ground it cannot stand on, so grounded is false there and a jump is refused.
Neither field can be written. A write is accepted and discarded rather than throwing, because the value is recomputed at the top of every step. A restored character recomputes both on its first step from the scene it landed in. Destroying the entity takes both computations off, and each field holds what the last step worked out as an ordinary value, the way every computed field does.
if(player.hasBehavior(CharacterControllerBehavior))
{
const airborne = !player.state.grounded;
const crouched = player.state.stance === 'crouching';
console.log(airborne, crouched);
}Stances
A stance is a capsule height and a speed under a name. wantsStance names the one a game is asking for, and stance answers with the one the character is in.
One rule decides it: the character is in the stance it asked for when that capsule fits where it is standing, and otherwise in the tallest one that does.
Fitting is the whole capsule, its radius as well as its height. A stance too wide for the gap the character is standing in is refused the way one too tall for the ceiling is.
The rule answers the cases a game runs into:
- A character asking to stand under a low ceiling stays crouched, and stands as soon as there is room.
- A character asking to stand from prone under a shelf gets crouching, because the character takes the tallest stance the geometry allows rather than stepping up one stance at a time.
- A
wantsStancenaming no entry instancesis a stance that does not fit, so the character falls to the tallest that does rather than the behavior failing over a name. - Deleting the entry the character is standing in leaves it in the tallest stance that fits.
A character starts in the tallest stance rather than in one called standing, so a game that replaced the record wholesale still starts somewhere real.
Two stances is the default and not a limit. A game with prone adds a third entry and drives it by writing wantsStance; it gets a stance name its animations can read, a speed of its own, and the same refusal rule. Which order the stances cycle in is a game's to sequence.
A stance shorter than twice its radius
A capsule is two half-spheres around a segment, so a stance shorter than twice its radius has no segment left and is not a capsule at all. Prone at a height of 0.4 with a radius of 0.3 is the ordinary case that runs into it.
Such a stance throws, naming the entity and the stance, rather than standing up the shortest capsule that radius allows, which is a shape nobody chose. It throws on the step a game asks for that stance, since a stance shorter than twice its radius can never be taken. stances is state and a game may write anything into it, and an entry nobody asks for has never shaped anything. A character built in an impossible stance and one that asks for it later fail the same way.
A stance exactly twice its radius is the shortest one that still is a capsule, no segment between the two half-spheres, and does not throw.
A stances with no entries at all leaves no stance to be in, and the fallback has no tallest stance to fall to. A character built with an empty record throws when it is built; a game that empties the record under a live character throws on its next step, naming the stance it was standing in.
The capsule
The capsule's height and radius both come from the current stance. Neither is written directly: a game changes shape by asking for a different stance, and changes what a stance means by writing that entry in stances. Writing the entry the character is standing in resizes the live capsule on the next step, and so does replacing the whole record.
The capsule is built in onMeshLoaded, because measuring needs a mesh and there is none before then. It is built where the node stands in the scene, in world space, under whatever parent the node has, adopted or built alike, and each step writes where the capsule ended up onto the node as a world position. A character under a parent away from the origin moves correctly in that parent's space, and its position reads the node's own transform there. See Entities.
Resizing holds the foot still and lets the head move, which is why position means the same thing in every stance and why a character that crouches stays on the floor it was standing on.
measureCapsule
measureCapsule is read once, when the capsule is built. When it is set, the tallest stance's height is taken from the full extent of the mesh's bounding box and its radius from the larger of that box's x and z half-extents; a character is rarely as deep as it is wide, and taking one axis picks the wrong one half the time. Every other stance keeps what it was declared with, since a mesh says how tall a character stands and nothing about how low it crouches.
Measuring is for a blockout, and declaring is the default. Arms out, a slung rifle, a cape: each widens the bounding box without widening the character, and the capsule that comes back is one that cannot fit through its own doorways.
Measuring happens when a character is created and not when one is restored. stances travels in the snapshot, so a restored character already carries the heights it was saved with. A character that asks to be measured and has nothing to measure throws, naming the entity.
The step
A character advances on the fixed step, with everything else, and never on a rendered frame. Which stance it is in is decided between the two halves of step 1, since the probe the ground cast sweeps is sized to it. Each step, in order:
- Ask what it is standing on, and find the body it is standing on.
- Work out the velocity it wants, from what the game is asking for, the speed of its stance, gravity, and whether a jump is owed.
- Add the velocity of whatever it is standing on.
- Sweep the capsule through the scene by the total.
- Write where it ended up onto the node.
velocity reports what the sweep achieved rather than what was asked for. A character walking into a wall stops, and so does the number. The carry is part of it too: a character standing still on a moving platform reports the platform's velocity as its own, because that is how fast it is going.
Step 2 is the one a game replaces. See what a game overrides.
Standing on something that moves
A character on a moving platform is carried by it, and the behavior does the carrying itself in step 3. Babylon's support query strips velocity out on purpose, and the surface velocity it reports back is derived from rendered frames rather than fixed steps, so it reads zero on most steps and always zero with no rendering at all.
Instead, a short shape cast straight down from the foot answers what is under the character and whose body it is, and the character asks that body how fast it is going. The cast reaches a little past the gap a settled capsule holds itself at, which is the controller's keep distance plus its contact tolerance, and the probe it sweeps is a sphere sized to the stance the character is in for the step.
Two surfaces can be under one foot at a join, and the two are not always going the same way. The character keeps the body it was already standing on while that body is still under it, and takes the cast's answer only when it is gone, so stepping onto a platform commits once instead of flickering between the platform and the floor it left.
A body that is moved carries its rider and a body that is placed does not. A kinematic body driven by writing position reports the velocity the move implies; an authored collider written straight onto its node reports none. A game that wants a platform ridden asks for the motion outright. See Physics.
Jump
A jump is asked for on the step a game writes wantsJump, and taken on that same step when one is allowed. When one is not allowed, the request is held rather than thrown away.
A request held for jumpBufferSteps is spent the moment a jump becomes possible, and a jump stays possible for coyoteSteps after the ground goes away. The first covers pressing slightly early, the second pressing slightly late. Both are step counts rather than seconds, and both are inclusive: a window of six is six steps on which the jump still works.
A jump is allowed when the character is supported or inside its coyote window. It is refused while sliding, so a slope too steep to hold cannot be climbed a step at a time, and refused in any stance but the tallest, since a shorter stance exists to fit under something a jump would drive the character into.
A jump keeps rising until the character's own vertical velocity turns over under gravity, which is applied on every step of the rise. The rise withholds the zeroing of vertical velocity that a supported character is otherwise held at. The support query still answers SUPPORTED on the step after takeoff, because the capsule has not cleared the surface yet, and that zeroing would cancel the rise one step into it.
What a game overrides
Two protected methods, both called during the step.
| Method | Answers | Overridden for |
|---|---|---|
desiredVelocity(duration, surface) | the velocity the character wants this step, in world space, before the surface it is standing on is added | air control, momentum, swimming, a dash, an accelerating walk |
stanceFits(stance) | whether that stance's capsule fits where the character is standing | a game whose answer is not simply overhead room |
The default desiredVelocity reads the fields a game writes, takes the current stance's speed and scales it by sprintScale while sprint is held, and applies gravity whenever the character is unsupported or rising. On the steps where it is supported and not rising, the floor holds its vertical velocity at zero.
The jump is not this method's. An override answers for what the character wants, and the behavior takes the jump around it, so a subclass that never calls the default keeps the jump, its buffer, and its coyote window.
import { Vector3 } from '@babylonjs/core';
import { CharacterControllerBehavior } from '@skewedaspect/sage';
export class AirControlCharacterBehavior extends CharacterControllerBehavior
{
protected override desiredVelocity(duration : number) : Vector3
{
return new Vector3(
this.state.moveX * 4,
this.state.velocity.y - (9.81 * duration),
this.state.moveZ * 4
);
}
}A subclass whose stanceFits answers no leaves the character in the stance it is in:
import { CharacterControllerBehavior } from '@skewedaspect/sage';
export class NeverGrowsCharacterBehavior extends CharacterControllerBehavior
{
protected override stanceFits(stance : string) : boolean
{
const wanted = this.state.stances[stance];
if(wanted === undefined)
{
return false;
}
return wanted.height <= this.state.stances[this.state.stance].height;
}
}For a stance stances holds, stanceFits throws, naming the entity, when it is asked before onMeshLoaded has built the capsule, and when it is asked after the entity is destroyed. For a stance stances does not hold, it answers no without reaching the physics engine.
Everything else a game changes by writing state, which is the point of keeping what a game asks for separate from how the character moves.
Lifecycle
The capsule and the node the controller made for it are disposed when the entity is destroyed. A body that outlives its entity is an invisible obstacle standing in the level.
Pooling is where that shows. A destroyed character hands its node back to the pool and the node survives, so nothing else disposes the capsule on the entity's behalf; the behavior disposes it either way, and the next character spawned there has somewhere to stand.
Errors
Every message names the entity, and the stance errors name the stance as well.
| When | Message |
|---|---|
| The scene has no physics engine | entity "walker" wants a character controller, and the scene it was spawned into has no physics engine. |
| A stance the character is built in or asks for is shorter than twice its radius | entity "walker" declares the stance "prone" at a height of 0.4 and a radius of 0.3, which is shorter than twice its radius and is not a capsule. |
measureCapsule is set and there is no mesh to measure | entity "walker" asked to measure its capsule from a mesh, and has nothing to measure. |
The character is built with an empty stances | entity "walker" has no stances to be in, and a character is always in one. |
A game empties stances under a live character | entity "walker" is in the stance "standing", and its `stances` holds no entry under that name. |
stanceFits is asked for a stance stances holds before the capsule is built, or after the entity is destroyed | entity "walker" has no capsule to answer for. |
Every message begins with CharacterControllerBehavior:.
Not covered
Ladders, mantling, swimming, and vaulting are not covered. Each of them replaces the ordinary support-and-sweep arrangement.
Nothing here says how a camera follows a character. A first-person camera parented to the capsule and a third-person camera on a boom are both a game's, and neither is a movement question.
