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

First Person Game

A complete first-person shooter/exploration template with physics, audio, and weapons.

Complete Example

use nightshade::prelude::*;
use nightshade::ecs::physics::commands::{
    spawn_static_physics_cube_with_material,
    spawn_dynamic_physics_cube_with_material,
};
use nightshade::ecs::physics::RigidBodyType;

struct FirstPersonGame {
    player: Option<Entity>,
    camera: Option<Entity>,
    weapon: Option<Entity>,
    health: f32,
    ammo: u32,
    score: u32,
    footstep_timer: f32,
    paused: bool,
}

impl Default for FirstPersonGame {
    fn default() -> Self {
        Self {
            player: None,
            camera: None,
            weapon: None,
            health: 100.0,
            ammo: 30,
            score: 0,
            footstep_timer: 0.0,
            paused: false,
        }
    }
}

impl State for FirstPersonGame {
    fn initialize(&mut self, world: &mut World) {
        self.setup_player(world);
        self.setup_level(world);
        self.setup_lighting(world);
        self.setup_ui(world);

        world.set_cursor_visible(false);
        world.set_cursor_locked(true);
    }

    fn run_systems(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;

        self.update_player_movement(world, dt);
        self.update_weapon_sway(world, dt);
        self.update_footsteps(world, dt);

        run_physics_systems(world);
        sync_transforms_from_physics_system(world);
    }

    fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Escape => self.toggle_pause(world),
                KeyCode::KeyR => self.reload_weapon(),
                _ => {}
            }
        }
    }

    fn on_mouse_input(&mut self, world: &mut World, state: ElementState, button: MouseButton) {
        if button == MouseButton::Left && state == ElementState::Pressed {
            self.fire_weapon(world);
        }
    }
}

impl FirstPersonGame {
    fn setup_player(&mut self, world: &mut World) {
        let player = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
                | CHARACTER_CONTROLLER,
            1,
        )[0];

        world.core.set_name(player, Name("Player".to_string()));
        world.core.set_local_transform(player, LocalTransform {
            translation: Vec3::new(0.0, 1.8, 0.0),
            ..Default::default()
        });

        if let Some(controller) = world.core.get_character_controller_mut(player) {
            *controller = CharacterControllerComponent::new_capsule(0.7, 0.3);
            controller.max_speed = 5.0;
            controller.acceleration = 20.0;
            controller.jump_impulse = 7.0;
        }

        let camera = world.spawn_entities(
            NAME | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY
                | CAMERA | PARENT,
            1,
        )[0];

        world.core.set_name(camera, Name("Player Camera".to_string()));
        world.core.set_local_transform(camera, LocalTransform {
            translation: Vec3::new(0.0, 0.7, 0.0),
            ..Default::default()
        });

        world.core.set_camera(camera, Camera {
            projection: Projection::Perspective(PerspectiveCamera {
                y_fov_rad: 75.0_f32.to_radians(),
                z_near: 0.1,
                z_far: Some(1000.0),
                aspect_ratio: None,
            }),
            smoothing: None,
        });

        world.core.set_parent(camera, Parent(Some(player)));
        world.resources.active_camera = Some(camera);

        self.setup_weapon(world, camera);
        self.player = Some(player);
        self.camera = Some(camera);
    }

    fn setup_weapon(&mut self, world: &mut World, camera: Entity) {
        let weapon = spawn_cube_at(world, Vec3::zeros());

        world.core.set_local_transform(weapon, LocalTransform {
            translation: Vec3::new(0.3, -0.2, -0.5),
            rotation: nalgebra_glm::quat_angle_axis(
                std::f32::consts::PI,
                &Vec3::y(),
            ),
            scale: Vec3::new(0.05, 0.05, 0.3),
        });

        set_material_with_textures(world, weapon, Material {
            base_color: [0.2, 0.2, 0.2, 1.0],
            roughness: 0.4,
            metallic: 0.9,
            ..Default::default()
        });

        world.core.set_parent(weapon, Parent(Some(camera)));
        self.weapon = Some(weapon);
    }

    fn setup_level(&mut self, world: &mut World) {
        spawn_static_physics_cube_with_material(
            world,
            Vec3::zeros(),
            Vec3::new(100.0, 0.2, 100.0),
            Material {
                base_color: [0.3, 0.3, 0.3, 1.0],
                roughness: 0.9,
                ..Default::default()
            },
        );

        for index in 0..10 {
            let angle = index as f32 * std::f32::consts::TAU / 10.0;
            let distance = 20.0;

            spawn_static_physics_cube_with_material(
                world,
                Vec3::new(angle.cos() * distance, 2.0, angle.sin() * distance),
                Vec3::new(5.0, 4.0, 0.5),
                Material {
                    base_color: [0.5, 0.5, 0.5, 1.0],
                    roughness: 0.8,
                    ..Default::default()
                },
            );
        }

        for index in 0..5 {
            spawn_dynamic_physics_cube_with_material(
                world,
                Vec3::new((index as f32 - 2.0) * 3.0, 0.5, -10.0),
                Vec3::new(1.0, 1.0, 1.0),
                10.0,
                Material {
                    base_color: [0.6, 0.4, 0.2, 1.0],
                    roughness: 0.8,
                    ..Default::default()
                },
            );
        }
    }

    fn setup_lighting(&mut self, world: &mut World) {
        spawn_sun(world);
        world.resources.graphics.ambient_light = [0.1, 0.1, 0.1, 1.0];
    }

    fn setup_ui(&mut self, world: &mut World) {
        spawn_ui_text(world, &format!("Health: {}", self.health as u32), Vec2::new(20.0, 550.0));

        spawn_ui_text(world, &format!("Ammo: {}", self.ammo), Vec2::new(700.0, 550.0));

        let crosshair = spawn_ui_text_with_properties(
            world,
            "+",
            Vec2::new(400.0, 300.0),
            TextProperties {
                font_size: 24.0,
                color: Vec4::new(1.0, 1.0, 1.0, 0.8),
                alignment: TextAlignment::Center,
                ..Default::default()
            },
        );
    }

    fn update_player_movement(&mut self, world: &mut World, dt: f32) {
        let Some(player) = self.player else { return };

        let keyboard = &world.resources.input.keyboard;
        let position_delta = world.resources.input.mouse.position_delta;

        let mut move_input = Vec3::zeros();
        if keyboard.is_key_pressed(KeyCode::KeyW) { move_input.z -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyS) { move_input.z += 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyA) { move_input.x -= 1.0; }
        if keyboard.is_key_pressed(KeyCode::KeyD) { move_input.x += 1.0; }

        if move_input.magnitude() > 0.0 {
            move_input = move_input.normalize();
        }

        let sprint = keyboard.is_key_pressed(KeyCode::ShiftLeft);
        let speed = if sprint { 8.0 } else { 5.0 };

        if let Some(controller) = world.core.get_character_controller_mut(player) {
            if let Some(transform) = world.core.get_local_transform(player) {
                let forward = transform.rotation * Vec3::new(0.0, 0.0, -1.0);
                let right = transform.rotation * Vec3::new(1.0, 0.0, 0.0);

                let forward_flat = Vec3::new(forward.x, 0.0, forward.z).normalize();
                let right_flat = Vec3::new(right.x, 0.0, right.z).normalize();

                let world_move = forward_flat * -move_input.z + right_flat * move_input.x;
                controller.velocity.x = world_move.x * speed;
                controller.velocity.z = world_move.z * speed;

                if keyboard.is_key_pressed(KeyCode::Space) && controller.grounded {
                    controller.velocity.y = controller.jump_impulse;
                }
            }
        }

        if let Some(transform) = world.core.get_local_transform_mut(player) {
            let sensitivity = 0.002;
            let yaw = nalgebra_glm::quat_angle_axis(
                -position_delta.x * sensitivity,
                &Vec3::y(),
            );
            transform.rotation = yaw * transform.rotation;
        }

        if let Some(camera) = world.resources.active_camera {
            if let Some(transform) = world.core.get_local_transform_mut(camera) {
                let sensitivity = 0.002;
                let pitch = nalgebra_glm::quat_angle_axis(
                    -position_delta.y * sensitivity,
                    &Vec3::x(),
                );
                transform.rotation = transform.rotation * pitch;
            }
        }
    }

    fn update_weapon_sway(&mut self, world: &mut World, dt: f32) {
        let Some(weapon) = self.weapon else { return };

        let position_delta = world.resources.input.mouse.position_delta;

        if let Some(transform) = world.core.get_local_transform_mut(weapon) {
            let target_x = 0.3 - position_delta.x * 0.001;
            let target_y = -0.2 - position_delta.y * 0.001;

            transform.translation.x += (target_x - transform.translation.x) * dt * 10.0;
            transform.translation.y += (target_y - transform.translation.y) * dt * 10.0;
        }
    }

    fn update_footsteps(&mut self, world: &mut World, dt: f32) {
        let Some(player) = self.player else { return };

        let keyboard = &world.resources.input.keyboard;
        let moving = keyboard.is_key_pressed(KeyCode::KeyW) ||
                     keyboard.is_key_pressed(KeyCode::KeyS) ||
                     keyboard.is_key_pressed(KeyCode::KeyA) ||
                     keyboard.is_key_pressed(KeyCode::KeyD);

        if let Some(controller) = world.core.get_character_controller(player) {
            if moving && controller.grounded {
                self.footstep_timer -= dt;
                if self.footstep_timer <= 0.0 {
                    self.footstep_timer = 0.4;
                }
            }
        }
    }

    fn fire_weapon(&mut self, world: &mut World) {
        if self.ammo == 0 {
            return;
        }

        self.ammo -= 1;

        let Some(camera) = world.resources.active_camera else { return };
        let Some(transform) = world.core.get_global_transform(camera) else { return };

        let origin = transform.translation();
        let direction = transform.forward_vector();

        for entity in world.core.query_entities(RIGID_BODY | GLOBAL_TRANSFORM) {
            let Some(entity_transform) = world.core.get_global_transform(entity) else {
                continue;
            };

            let to_entity = entity_transform.translation() - origin;
            let distance = to_entity.magnitude();

            if distance > 100.0 || distance < 0.1 {
                continue;
            }

            let dot = direction.dot(&to_entity.normalize());
            if dot > 0.99 {
                if let Some(body) = world.core.get_rigid_body_mut(entity) {
                    if body.body_type == RigidBodyType::Dynamic {
                        body.linvel = [
                            body.linvel[0] + direction.x * 10.0,
                            body.linvel[1] + direction.y * 10.0,
                            body.linvel[2] + direction.z * 10.0,
                        ];
                    }
                }
                break;
            }
        }
    }

    fn reload_weapon(&mut self) {
        self.ammo = 30;
    }

    fn toggle_pause(&mut self, world: &mut World) {
        self.paused = !self.paused;
        world.set_cursor_visible(self.paused);
        world.set_cursor_locked(!self.paused);
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    nightshade::launch(FirstPersonGame::default())
}

Key Components

Character Controller

The character controller handles physics-based movement:

#![allow(unused)]
fn main() {
if let Some(controller) = world.core.get_character_controller_mut(player) {
    *controller = CharacterControllerComponent::new_capsule(0.7, 0.3);
    controller.max_speed = 5.0;
    controller.acceleration = 20.0;
    controller.jump_impulse = 7.0;
}
}

Camera Setup

First-person camera is parented to the player:

#![allow(unused)]
fn main() {
world.core.set_parent(camera, Parent(Some(player)));
}

This makes the camera follow the player automatically.

Weapon Attachment

The weapon is parented to the camera so it stays in view:

#![allow(unused)]
fn main() {
world.core.set_parent(weapon, Parent(Some(camera)));
}

Mouse Look

Horizontal rotation (yaw) goes on the player body, vertical rotation (pitch) goes on the camera:

#![allow(unused)]
fn main() {
transform.rotation = yaw * transform.rotation;

transform.rotation = transform.rotation * pitch;
}

This prevents gimbal lock and feels natural.

Physics Spawning

Static objects (floors, walls) use spawn_static_physics_cube_with_material. Dynamic objects (crates) use spawn_dynamic_physics_cube_with_material:

#![allow(unused)]
fn main() {
use nightshade::ecs::physics::commands::{
    spawn_static_physics_cube_with_material,
    spawn_dynamic_physics_cube_with_material,
};

spawn_static_physics_cube_with_material(world, position, size, material);
spawn_dynamic_physics_cube_with_material(world, position, size, mass, material);
}

Cargo.toml

[package]
name = "fps-game"
version = "0.1.0"
edition = "2024"

[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }