Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Character Controllers

A character controller is a kinematic body with a custom contact resolver on top. The constraint solver is what makes a stack of boxes stable, and it is also what makes a player character feel mushy when standing on those boxes. Character controllers bypass it. Movement happens in code, contacts are resolved by sweeping and sliding the capsule against the world, and the result is movement that snaps to walls without bouncing off them.

First-Person Player

The fastest way to get a working player is spawn_first_person_player. It creates the controller entity, a child camera at eye height, and wires up input.

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::spawn_first_person_player;
use nightshade::ecs::physics::character_controller::character_controller_input_system;

fn initialize(&mut self, world: &mut World) {
    let (player_entity, camera_entity) = spawn_first_person_player(
        world,
        Vec3::new(0.0, 2.0, 0.0),
    );

    self.player = Some(player_entity);

    if let Some(controller) = world.core.get_character_controller_mut(player_entity) {
        controller.max_speed = 5.0;
        controller.is_sprinting = false;
        controller.jump_impulse = 6.0;
    }
}
}

max_speed is in meters per second. jump_impulse is the instantaneous upward velocity applied when the jump input fires.

Custom Character Controller

For third-person, NPCs, or anything that needs custom dimensions, build the entity by hand.

#![allow(unused)]
fn main() {
fn spawn_character(world: &mut World, position: Vec3) -> Entity {
    let entity = world.spawn_entities(
        NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CHARACTER_CONTROLLER,
        1,
    )[0];

    world.core.set_name(entity, Name("Player".to_string()));
    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    if let Some(controller) = world.core.get_character_controller_mut(entity) {
        *controller = CharacterControllerComponent::new_capsule(0.5, 0.3);
        controller.max_speed = 3.0;
        controller.acceleration = 15.0;
        controller.jump_impulse = 4.0;
        controller.is_sprinting = false;
        controller.is_crouching = false;
    }

    entity
}
}

new_capsule(0.5, 0.3) is half-height 0.5 and radius 0.3. The full character is 1 meter tall and 0.6 meters wide, roughly humanoid proportions.

Controller Properties

PropertyDescriptionDefault
max_speedWalking speed5.0
is_sprintingSprint activefalse
accelerationSpeed up rate20.0
jump_impulseJump strength5.0
can_jumpAllow jumpingtrue
is_crouchingCrouch activefalse

Movement Input

The built-in character_controller_input_system(world) reads WASD, space, shift, and control, and writes the resulting motion into the controller each frame. Call it from run_systems.

#![allow(unused)]
fn main() {
fn run_systems(&mut self, world: &mut World) {
    character_controller_input_system(world);
}
}

Skip this call and the controller will only move when something else writes to it. That is the right choice when input comes from a network packet or an AI script.

Ground Detection

The controller flags whether the capsule is currently in contact with a surface below it.

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller(player) {
    if controller.grounded {
        // On ground - can jump
    } else {
        // In air
    }
}
}

Use this for jump gating, footstep timing, and switching between ground and air movement.

Slope Handling

The controller has two slope angles. Anything below max_slope_climb_angle is walkable. Anything above min_slope_slide_angle will slide the character down. Values are radians.

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    controller.config.max_slope_climb_angle = 0.8;
    controller.config.min_slope_slide_angle = 0.5;
}
}

0.8 rad is about 45 degrees. 0.5 rad is about 30 degrees. Tune for the world's geometry.

Camera Integration

First-Person Camera

The camera is a child entity of the player, offset to eye height.

#![allow(unused)]
fn main() {
let camera = world.spawn_entities(
    LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY | CAMERA | PARENT,
    1
)[0];

world.core.set_parent(camera, Parent(Some(player)));
world.core.set_local_transform(camera, LocalTransform {
    translation: Vec3::new(0.0, 0.8, 0.0),
    ..Default::default()
});

world.resources.active_camera = Some(camera);
}

Parenting means the transform hierarchy handles position. Mouse look writes to the camera's local rotation, body rotation goes on the player.

Third-Person Camera

Third-person follows the character with an offset and looks at a target point on or near the body.

#![allow(unused)]
fn main() {
fn third_person_camera_system(world: &mut World, player: Entity, camera: Entity) {
    let Some(player_pos) = world.core.get_local_transform(player).map(|t| t.translation) else {
        return;
    };

    let offset = Vec3::new(0.0, 3.0, 8.0);
    let target_pos = player_pos + offset;

    if let Some(pan_orbit) = world.core.get_pan_orbit_camera_mut(camera) {
        pan_orbit.target_focus = player_pos + Vec3::new(0.0, 1.0, 0.0);
    }
}
}

Step Climbing

Automatic step climbing lets the controller walk over small ledges without jumping. The two parameters bound what counts as a step.

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    controller.config.autostep_max_height = Some(0.3);
    controller.config.autostep_min_width = Some(0.2);
}
}

30 cm is roughly a typical stair height. Anything taller than autostep_max_height is treated as a wall.

Interaction Cooldowns

Player actions that fire on a button press need cooldowns so a long key press does not fire the action every frame.

#![allow(unused)]
fn main() {
struct PlayerState {
    interaction_cooldown: f32,
}

fn update_cooldown(state: &mut PlayerState, dt: f32) {
    state.interaction_cooldown = (state.interaction_cooldown - dt).max(0.0);
}

fn can_interact(state: &PlayerState) -> bool {
    state.interaction_cooldown <= 0.0
}

fn set_cooldown(state: &mut PlayerState, duration: f32) {
    state.interaction_cooldown = duration;
}
}

A just_pressed check on the input also works and is simpler. Use cooldowns when the action should repeat at a fixed rate while the key is held, like an auto-fire weapon.