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:

ConfigBackendHow Collisions Are Resolved
physics.enabled: trueBEPU physics engineEngine-level capsule casts
physics.enabled: falseHeightmap + collider mathDistance 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:

  1. Network input arrives from the client (move direction, look direction, sprint, jump)
  2. Intents are stored on the controller via MoveIntent, LookIntent, SprintIntent, JumpIntent
  3. On the next engine tick, Step() consumes the intents and computes movement via capsule sweeps
  4. The resulting position/rotation are applied to the entity
Loading diagram...

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

PropertyDefaultDescription
MoveSpeed5Walk speed (m/s)
SprintSpeed5Sprint speed (m/s). Set higher than MoveSpeed to enable sprinting
Acceleration1000How fast the character reaches target speed (m/s^2). High default = instant
Deceleration1000How 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

PropertyDefaultDescription
Gravity25Downward acceleration (m/s^2)
MaxFallSpeed50Maximum fall velocity clamp

Air Control

PropertyDefaultDescription
AirAccelerationMultiplier1Multiplier on acceleration while airborne. Set to 0.3 for limited air control
AirMaxSpeedMultiplier1Multiplier on max speed while airborne

Ground Detection

PropertyDefaultDescription
GroundProbeDistance0.12How far below the capsule to probe for ground
SkinWidth0.03Small gap maintained between capsule and surfaces
MaxSlopeAngleDeg60Maximum walkable slope angle in degrees
GroundSnapSpeed2Small downward velocity applied when grounded (prevents floating)
GroundMaskPhysxLayer.WorldWhich 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

PropertyDefaultDescription
UseCapsuleSweepstrueUse capsule sweep for collision. Disable for ray-only (cheaper but less accurate)
MaxSlideIterations3Maximum collision slide iterations per tick
CollisionMaskWorld | DynamicWhich physics layers to collide with during movement

Depenetration

PropertyDefaultDescription
UseDepenetrationtruePush the character out of overlapping geometry
DepenetrationIterations3Maximum push-out attempts per tick
DepenetrationPushDistance0.03Distance 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

PropertyDefaultDescription
FaceMoveDirectiontrueCharacter faces movement direction. Set false for strafe-style movement
RotationSpeedDegPerSec0Rotation smoothing (0 = instant snap to target yaw)
UseExternalFacingYawfalseOverride rotation with an external yaw (e.g., for lock-on combat)
ExternalFacingYaw0The yaw to face when UseExternalFacingYaw is enabled (radians)

Character Shape

PropertyDefaultDescription
Radius0.28Capsule radius (must match body profile)
Height1.8Capsule 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);
}
IntentParametersDescription
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:

  1. Depenetrate — push out of any overlapping geometry from the previous frame
  2. Ground probe — capsule sweep downward to detect ground and slope angle
  3. Compute desired move — transform local input into world-space direction using camera yaw
  4. Rotation — yaw toward move direction (or camera, or external facing)
  5. Run abilities — each ICharacterAbility3D modifies the CharacterMotorContext
  6. Gravity + ground snap — apply gravity if airborne, snap to ground if grounded
  7. Horizontal acceleration — accelerate toward desired velocity, decelerate when no input
  8. Sweep + slide — capsule sweep in the movement direction, slide along hit surfaces
  9. Post-move depenetration — final push-out pass for numerical edge cases
  10. Store state — update IsGrounded, Velocity for 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.