Movement & Character Controller
Altruist provides a built-in Kinematic Character Controller (KinematicCharacterController3D) for server-authoritative player movement. It handles camera-relative motion, gravity, ground detection, capsule sweep collision, depenetration, slope limits, and an extensible ability system.
The controller works with and without physics. Altruist auto-selects the right backend:
| Config | Backend | How Collisions Are Resolved |
|---|---|---|
physics.enabled: true | BEPU physics engine | Engine-level capsule casts |
physics.enabled: false | Heightmap + collider math | Distance checks against entity ColliderDescriptors |
Your controller code is identical in both cases. The backend is swapped via ISpatialQueryProvider, which the framework injects automatically based on your config.
How It Works
The controller follows an intent-based model:
- Network input arrives from the client (move direction, look direction, sprint, jump)
- Intents are stored on the controller via
MoveIntent,LookIntent,SprintIntent,JumpIntent - On the next engine tick,
Step()consumes the intents and computes movement via capsule sweeps - The resulting position/rotation are applied to the entity
Note:
The controller is server-authoritative. The client sends input intents, the server computes the actual position. This prevents speed hacks and teleport exploits.
IKinematicCharacterController3D Interface
public interface IKinematicCharacterController3D
{
// Attach the physics body
void SetBody(IPhysxBody3D body);
// Input intents (called by netcode)
void MoveIntent(float moveX, float moveZ);
void LookIntent(float lookX, float lookY, float lookZ);
void SprintIntent(bool sprintHeld);
void JumpIntent(bool jumpPressed);
// Simulation tick (called by prefab/world Step)
void Step(float dt, IGameWorldManager3D world);
// State outputs (for replication)
Vector3 Position { get; }
Quaternion Rotation { get; }
bool IsGrounded { get; }
Vector3 Velocity { get; }
}
Setting Up a Character Controller
public async Task SetupCharacter(CharacterPrefab prefab, IGameWorldManager3D world)
{
// 1. Create body profile (capsule shape for humanoid)
var profile = new HumanoidCapsuleBodyProfile(
radius: 0.28f,
halfLength: 0.62f, // (height/2) - radius
mass: 0f,
isKinematic: true
);
// 2. Set physics descriptors on the prefab
prefab.BodyDescriptor = profile.CreateBody(prefab.Transform);
prefab.ColliderDescriptors = profile.CreateColliders(prefab.Transform);
// 3. Spawn into world (creates the runtime physics body)
var body = await world.SpawnDynamicObject(prefab);
// 4. Create and configure the controller
var controller = new KinematicCharacterController3D
{
MoveSpeed = 5f,
SprintSpeed = 8f,
Gravity = 25f,
MaxSlopeAngleDeg = 60f,
Radius = 0.28f,
Height = 1.8f,
};
// 5. Add abilities
controller.AddAbility(new SimpleJumpAbility3D { JumpSpeed = 7.5f });
// 6. Attach the runtime physics body
controller.SetBody(body);
}
Note:
The Radius and Height on the controller must match the body profile dimensions. These values are used for capsule sweeps and ground probing — mismatched values cause incorrect collision and grounding.
Configuration Reference
Speed & Acceleration
| Property | Default | Description |
|---|---|---|
MoveSpeed | 5 | Walk speed (m/s) |
SprintSpeed | 5 | Sprint speed (m/s). Set higher than MoveSpeed to enable sprinting |
Acceleration | 1000 | How fast the character reaches target speed (m/s^2). High default = instant |
Deceleration | 1000 | How fast the character stops (m/s^2). High default = instant |
Note:
The default Acceleration and Deceleration of 1000 make movement feel instant (no drift). Lower these for more realistic inertia — e.g., Acceleration = 20, Deceleration = 15 for a sluggish heavy character.
Gravity & Vertical
| Property | Default | Description |
|---|---|---|
Gravity | 25 | Downward acceleration (m/s^2) |
MaxFallSpeed | 50 | Maximum fall velocity clamp |
Air Control
| Property | Default | Description |
|---|---|---|
AirAccelerationMultiplier | 1 | Multiplier on acceleration while airborne. Set to 0.3 for limited air control |
AirMaxSpeedMultiplier | 1 | Multiplier on max speed while airborne |
Ground Detection
| Property | Default | Description |
|---|---|---|
GroundProbeDistance | 0.12 | How far below the capsule to probe for ground |
SkinWidth | 0.03 | Small gap maintained between capsule and surfaces |
MaxSlopeAngleDeg | 60 | Maximum walkable slope angle in degrees |
GroundSnapSpeed | 2 | Small downward velocity applied when grounded (prevents floating) |
GroundMask | PhysxLayer.World | Which physics layers count as "ground" |
Note:
MaxSlopeAngleDeg controls which surfaces are walkable. A slope steeper than this angle is treated as a wall — the character slides off instead of walking up.
Collision
| Property | Default | Description |
|---|---|---|
UseCapsuleSweeps | true | Use capsule sweep for collision. Disable for ray-only (cheaper but less accurate) |
MaxSlideIterations | 3 | Maximum collision slide iterations per tick |
CollisionMask | World | Dynamic | Which physics layers to collide with during movement |
Depenetration
| Property | Default | Description |
|---|---|---|
UseDepenetration | true | Push the character out of overlapping geometry |
DepenetrationIterations | 3 | Maximum push-out attempts per tick |
DepenetrationPushDistance | 0.03 | Distance to push out per iteration |
Note:
Depenetration handles edge cases like spawning inside geometry or being pushed into a wall by a moving platform. Keep it enabled unless you have a specific reason to disable it.
Rotation
| Property | Default | Description |
|---|---|---|
FaceMoveDirection | true | Character faces movement direction. Set false for strafe-style movement |
RotationSpeedDegPerSec | 0 | Rotation smoothing (0 = instant snap to target yaw) |
UseExternalFacingYaw | false | Override rotation with an external yaw (e.g., for lock-on combat) |
ExternalFacingYaw | 0 | The yaw to face when UseExternalFacingYaw is enabled (radians) |
Character Shape
| Property | Default | Description |
|---|---|---|
Radius | 0.28 | Capsule radius (must match body profile) |
Height | 1.8 | Capsule total height (must match body profile) |
Feeding Input from Network
When receiving movement packets from the client, feed them as intents:
[Gate("move")]
public void OnMove(MovePacket packet, string clientId)
{
var prefab = GetPlayerPrefab(clientId);
prefab.Controller.MoveIntent(packet.InputX, packet.InputZ);
prefab.Controller.LookIntent(packet.LookX, packet.LookY, packet.LookZ);
prefab.Controller.SprintIntent(packet.Sprint);
prefab.Controller.JumpIntent(packet.Jump);
}
| Intent | Parameters | Description |
|---|---|---|
MoveIntent | (float x, float z) | Local-space input direction. Typically -1 to 1 on each axis from the client's input |
LookIntent | (float x, float y, float z) | Camera forward direction vector. Used to compute camera-relative movement |
SprintIntent | (bool held) | Whether the sprint key is held |
JumpIntent | (bool pressed) | Whether the jump key is pressed |
Note:
Intents are consumed once per tick. If multiple packets arrive between ticks, only the last values are used. This is intentional — the server runs at a fixed tick rate, not at network packet rate.
Integrating with a Prefab
The controller doesn't run on its own — you call Controller.Step() inside your prefab's Step() override:
[Prefab("character")]
[WorldObject("character")]
public class CharacterPrefab : WorldObjectPrefab3D
{
[PrefabComponentRoot]
public CharacterVault Character { get; set; } = default!;
public IKinematicCharacterController3D Controller { get; set; } = default!;
public override void Step(float dt, IGameWorldManager3D world)
{
if (Body is not IPhysxBody3D body || Controller is null) return;
// 1. Run the controller physics
Controller.Step(dt, world);
// 2. Sync transform from physics body
Transform = Transform
.WithPosition(Position3D.From(body.Position))
.WithRotation(Rotation3D.FromQuaternion(body.Rotation));
// 3. Persist to vault for database save
Character.X = Transform.Position.X;
Character.Y = Transform.Position.Y;
Character.Z = Transform.Position.Z;
}
}
Note:
The controller modifies body.Position and body.Rotation directly. Your Step() reads those back into the world object's Transform and optionally persists to the vault.
The Step Cycle
Each tick, the controller performs these steps in order:
- Depenetrate — push out of any overlapping geometry from the previous frame
- Ground probe — capsule sweep downward to detect ground and slope angle
- Compute desired move — transform local input into world-space direction using camera yaw
- Rotation — yaw toward move direction (or camera, or external facing)
- Run abilities — each
ICharacterAbility3Dmodifies theCharacterMotorContext - Gravity + ground snap — apply gravity if airborne, snap to ground if grounded
- Horizontal acceleration — accelerate toward desired velocity, decelerate when no input
- Sweep + slide — capsule sweep in the movement direction, slide along hit surfaces
- Post-move depenetration — final push-out pass for numerical edge cases
- Store state — update
IsGrounded,Velocityfor the next tick
Abilities
Abilities are modular hooks that run during step 5 of the cycle. They receive a mutable CharacterMotorContext and can modify velocity, grounding state, and input.
CharacterMotorContext
public struct CharacterMotorContext
{
public IPhysxBody3D Body;
public IGameWorldManager3D World;
public Vector3 DesiredMoveWorld; // Normalized input direction in world space
public float DesiredSpeed; // Target speed (m/s)
public Vector3 Velocity; // Current velocity (mutable — abilities modify this)
public bool IsGrounded;
public Vector3 GroundNormal;
public bool JumpPressed;
public bool SprintHeld;
}
Note:
Abilities modify the context by reference. Multiple abilities compose naturally — a jump ability sets vertical velocity, a dash ability overrides horizontal velocity, and they don't conflict.
Built-in: SimpleJumpAbility3D
controller.AddAbility(new SimpleJumpAbility3D { JumpSpeed = 7.5f });
Detects a rising edge on JumpPressed while grounded, then sets Velocity.Y = JumpSpeed.
Custom Ability: Dash
public class DashAbility : ICharacterAbility3D
{
public float DashSpeed { get; set; } = 20f;
public float DashDuration { get; set; } = 0.2f;
private float _dashTimer;
private bool _wasDashPressed;
public void Step(float dt, ref CharacterMotorContext ctx)
{
bool dashPressed = ctx.SprintHeld;
bool pressedThisFrame = dashPressed && !_wasDashPressed;
_wasDashPressed = dashPressed;
if (_dashTimer > 0f)
{
_dashTimer -= dt;
if (ctx.DesiredMoveWorld.LengthSquared() > 0.01f)
{
var dashDir = Vector3.Normalize(ctx.DesiredMoveWorld);
ctx.Velocity = new Vector3(
dashDir.X * DashSpeed,
ctx.Velocity.Y,
dashDir.Z * DashSpeed);
}
return;
}
if (pressedThisFrame && ctx.IsGrounded)
{
_dashTimer = DashDuration;
}
}
}
controller.AddAbility(new DashAbility { DashSpeed = 25f, DashDuration = 0.15f });
Custom Ability: Double Jump
public class DoubleJumpAbility : ICharacterAbility3D
{
public float JumpSpeed { get; set; } = 6f;
public int MaxAirJumps { get; set; } = 1;
private int _airJumpsUsed;
private bool _wasPressed;
private bool _wasGrounded;
public void Step(float dt, ref CharacterMotorContext ctx)
{
// Reset air jumps when landing
if (ctx.IsGrounded && !_wasGrounded)
_airJumpsUsed = 0;
_wasGrounded = ctx.IsGrounded;
bool pressedThisFrame = ctx.JumpPressed && !_wasPressed;
_wasPressed = ctx.JumpPressed;
if (!pressedThisFrame) return;
// Airborne jump
if (!ctx.IsGrounded && _airJumpsUsed < MaxAirJumps)
{
ctx.Velocity = new Vector3(ctx.Velocity.X, JumpSpeed, ctx.Velocity.Z);
ctx.IsGrounded = false;
_airJumpsUsed++;
}
}
}
controller.AddAbility(new SimpleJumpAbility3D { JumpSpeed = 7.5f });
controller.AddAbility(new DoubleJumpAbility { JumpSpeed = 6f, MaxAirJumps = 1 });
Note:
Abilities run in the order they are added. Put the ground jump ability first so it handles the grounded case, then the double jump handles the airborne case.
State Outputs
After Step(), the controller exposes the resulting state for replication:
Vector3 pos = controller.Position; // Current body position
Quaternion rot = controller.Rotation; // Current body rotation
bool grounded = controller.IsGrounded; // Whether on ground
Vector3 vel = controller.Velocity; // Current velocity
Use these to build position sync packets:
// In your broadcast service / visibility handler
var syncPacket = new PositionSyncPacket
{
VirtualId = prefab.VirtualId,
X = controller.Position.X,
Y = controller.Position.Y,
Z = controller.Position.Z,
Yaw = ExtractYaw(controller.Rotation),
Speed = controller.Velocity.Length(),
Grounded = controller.IsGrounded,
};
Body Profiles
A body profile encapsulates the physics shape for an entity. The controller requires a kinematic capsule body:
var profile = new HumanoidCapsuleBodyProfile(
radius: 0.28f,
halfLength: 0.62f, // (totalHeight / 2) - radius
mass: 0f,
isKinematic: true
);
prefab.BodyDescriptor = profile.CreateBody(prefab.Transform);
prefab.ColliderDescriptors = profile.CreateColliders(prefab.Transform);
Note:
Set isKinematic: true for character controllers. Kinematic bodies are moved directly by the controller rather than by physics forces, which gives precise control over movement.
Custom Body Profile
For non-humanoid characters (vehicles, mounts, large creatures), create a custom profile:
public class VehicleBodyProfile : IBodyProfile3D
{
public float Width { get; }
public float Height { get; }
public float Length { get; }
public float Mass { get; }
public VehicleBodyProfile(float width, float height, float length, float mass)
{
Width = width; Height = height; Length = length; Mass = mass;
}
public PhysxBody3DDesc CreateBody(Transform3D transform)
{
var sized = transform.WithSize(Size3D.Of(Width / 2, Height / 2, Length / 2));
return PhysxBody3D.Create(PhysxBodyType.Dynamic, Mass, sized);
}
public IEnumerable<PhysxCollider3DDesc> CreateColliders(Transform3D transform)
{
var sized = transform.WithSize(Size3D.Of(Width / 2, Height / 2, Length / 2));
yield return PhysxCollider3D.Create(PhysxColliderShape3D.Box3D, sized, isTrigger: false);
}
}
Rotation Modes
The controller supports three rotation modes depending on your game's camera style:
Face Move Direction (default)
controller.FaceMoveDirection = true;
controller.RotationSpeedDegPerSec = 0; // 0 = instant snap
The character faces the direction they're moving. Standard for third-person action games.
Face Camera Direction
controller.FaceMoveDirection = false;
The character always faces the camera's forward direction. Good for shooters or strafe-heavy games.
External Facing (Lock-On)
controller.UseExternalFacingYaw = true;
controller.ExternalFacingYaw = MathF.Atan2(
targetPos.X - playerPos.X,
targetPos.Z - playerPos.Z);
The character faces a specific yaw you set externally. Useful for lock-on combat where the player always faces the enemy.
Note:
Set RotationSpeedDegPerSec to a value like 720 for smooth rotation instead of instant snapping. This makes turning feel more natural, especially for third-person games.