First Person Game
A first-person template with a physics character controller, mouse look, weapon attachment, footsteps, and a simple hit-and-push interaction with dynamic crates. Every piece is in the single source file below. The text after the code explains the load-bearing parts.
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 is a capsule with a velocity that the physics step pushes around. Configure the shape and the kinematic parameters once at spawn:
#![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; } }
Each frame, the movement code writes the desired horizontal velocity into controller.velocity.x and controller.velocity.z. The physics step resolves collisions against the level geometry and updates the entity's transform.
Camera setup
The first-person camera is parented to the player body:
#![allow(unused)] fn main() { world.core.set_parent(camera, Parent(Some(player))); }
The parent relationship is what makes the camera follow the player. There is no per-frame copy. The transform propagation pass walks the hierarchy once and the camera's global transform falls out of the player's translation plus the camera's local offset.
Weapon attachment
The weapon is parented to the camera, not the player, so it stays in the lower-right corner of the view regardless of where the player is looking:
#![allow(unused)] fn main() { world.core.set_parent(weapon, Parent(Some(camera))); }
Mouse look
Yaw rotation goes on the player body. Pitch goes on the camera. Splitting the two axes onto different entities is what prevents gimbal-lock-looking artifacts when the camera tilts up or down:
#![allow(unused)] fn main() { transform.rotation = yaw * transform.rotation; transform.rotation = transform.rotation * pitch; }
The player body rotates around its own Y axis, which is always world-vertical. The camera then pitches relative to the body. The order of multiplication is significant. Yaw is left-multiplied (world-space rotation around vertical), pitch is right-multiplied (local-space rotation around the camera's own right axis).
Physics spawning
Two helpers cover the common cases. Static objects (floors, walls) never move, never accumulate forces, and are cheap. Dynamic objects (crates, props) participate in the full physics simulation and need a mass.
#![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); }
The fire-weapon path uses RigidBodyType::Dynamic as the gate before applying impulse, so static geometry shrugs off shots and only the crates respond.
Cargo.toml
[package]
name = "fps-game"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "...", features = ["engine", "wgpu", "physics"] }