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

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:

MethodPurpose
initializeOne-shot setup at startup
run_systemsGame logic every frame
on_keyboard_inputKey press and release events
on_mouse_inputMouse button events
on_gamepad_eventGamepad events
configure_render_graphCustom rendering hook
next_stateState 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_path and spawn_prefab_with_animations.
  • Add skeletal animation.
  • Add audio with Kira.

The other examples in this section show each of these in a complete program.