Minimal Example
The smallest Nightshade application is a struct that implements State, a single call to launch from main, and three lines of setup in initialize. Anything beyond that is a feature the application chose to add.
Complete code
use nightshade::prelude::*; struct MinimalGame; impl State for MinimalGame { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0)); spawn_sun(world); } fn run_systems(&mut self, world: &mut World) { fly_camera_system(world); } } fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MinimalGame) }
That is the whole program. A window with a cube, a sun, a fly camera, and an empty game loop.
Step-by-step breakdown
1. Import the prelude
#![allow(unused)] fn main() { use nightshade::prelude::*; }
The prelude re-exports the common surface. The State trait, the World struct, the Entity type, math types (Vec3, Vec4, Mat4), component flag constants (LOCAL_TRANSFORM, RENDER_MESH, and friends), and the helper spawn functions used in this file. Importing the prelude is the convention.
2. Define the state struct
#![allow(unused)] fn main() { struct MinimalGame; }
The state struct holds the game's own data. It can be empty for a demo with no state, like this one. For a real game it would hold whatever the game needs:
#![allow(unused)] fn main() { struct MinimalGame { score: u32, player: Option<Entity>, enemies: Vec<Entity>, } }
The engine does not own this data. It owns the World. The state struct lives on the user side of the boundary.
3. Implement the State trait
#![allow(unused)] fn main() { impl State for MinimalGame { fn initialize(&mut self, world: &mut World) { // Called once at startup } } }
Every method on State is optional. The methods present on the trait:
| Method | Purpose |
|---|---|
initialize | One-shot setup at startup |
run_systems | Game logic every frame |
on_keyboard_input | Key press and release events |
on_mouse_input | Mouse button events |
on_gamepad_event | Gamepad events |
configure_render_graph | Custom rendering hook |
next_state | State transition hook |
Implement the ones you need. Skip the rest.
4. Set up the scene
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0)); spawn_sun(world); } }
spawn_camera creates a camera entity at the given position with a default perspective projection. Setting it as the active camera tells the renderer which one to draw from. spawn_cube_at and spawn_sun are convenience wrappers that produce a cube mesh entity and a directional light entity respectively.
5. Run the application
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MinimalGame) }
launch does the rest. It creates the window, initializes the wgpu renderer, calls initialize on the state once, then enters the game loop. Each frame, it polls input, calls run_systems, and renders. The function does not return until the window closes.
Cargo.toml
[package]
name = "minimal-game"
version = "0.1.0"
edition = "2024"
[dependencies]
nightshade = { git = "https://github.com/user/nightshade", features = ["engine", "wgpu"] }
Running
cargo run --release
Release mode is meaningful here. Debug builds spend most of their frame time inside math routines that the optimizer collapses to nothing in release.
Controls
The fly camera is the engine's default debug camera. Standard controls:
- WASD moves horizontally.
- Space and Shift move up and down.
- Mouse looks around.
- Escape releases the cursor.
Extending the example
Add more objects
A loop is enough to fill the scene with cubes:
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); spawn_plane_at(world, Vec3::zeros()); for index in 0..5 { spawn_cube_at(world, Vec3::new(index as f32 * 2.0 - 4.0, 0.5, -5.0)); } spawn_sun(world); } }
Add animation
Add a field to track time, then rotate an entity each frame based on its accumulated value:
#![allow(unused)] fn main() { struct MinimalGame { cube: Option<Entity>, time: f32, } impl State for MinimalGame { fn initialize(&mut self, world: &mut World) { let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); self.cube = Some(spawn_cube_at(world, Vec3::new(0.0, 0.0, -5.0))); spawn_sun(world); } fn run_systems(&mut self, world: &mut World) { let dt = world.resources.window.timing.delta_time; self.time += dt; if let Some(cube) = self.cube { if let Some(transform) = world.core.get_local_transform_mut(cube) { transform.rotation = nalgebra_glm::quat_angle_axis( self.time, &Vec3::y(), ); } } } } }
The cube spins around the Y axis at one radian per second.
Add input handling
on_keyboard_input fires on press and release. Match on the key and the state:
#![allow(unused)] fn main() { impl State for MinimalGame { fn on_keyboard_input(&mut self, world: &mut World, key: KeyCode, state: ElementState) { if state == ElementState::Pressed { match key { KeyCode::Escape => std::process::exit(0), KeyCode::Space => self.spawn_cube(world), _ => {} } } } } }
This is the event form. For continuous input (movement keys held down), use world.resources.input.keyboard.is_key_pressed() inside run_systems instead.
What's next
From this foundation, the natural next steps:
- Add physics with rigid bodies and colliders.
- Load 3D models with
import_gltf_from_pathandspawn_prefab_with_animations. - Add skeletal animation.
- Add audio with Kira.
The other examples in this section show each of these in a complete program.