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"] }