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

Tutorial: Building a 3D Game

This tutorial builds a 3D Pong game from an empty project. The end state has two paddles, a bouncing ball, an AI opponent, scoring, pause and unpause, and a game-over screen, all rendered in 3D with PBR materials. The point of going through it is not Pong. It is to see how a complete game is structured in Nightshade, from window setup to game logic to syncing visuals from logic back into the ECS.

The pattern that emerges is the dual-world pattern. Game state lives in your own struct. The ECS holds rendered entities. Each frame, game logic mutates the struct, then a sync pass writes the new positions back to the ECS transforms. This separation keeps the ECS surface small and keeps game logic in plain Rust where it is easiest to reason about.

Project setup

cargo init pong-game

Cargo.toml:

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

[dependencies]
nightshade = { git = "https://github.com/user/nightshade", features = ["engine", "wgpu"] }
rand = "0.9"

The engine aggregate feature pulls in transforms, materials, lighting, and audio. wgpu selects the wgpu backend.

Step 1: The empty window

A Nightshade application is a struct that implements the State trait and a single call to launch:

use nightshade::prelude::*;

struct PongGame;

impl State for PongGame {
    fn initialize(&mut self, world: &mut World) {
        world.resources.window.title = "Pong".to_string();
        let camera = spawn_camera(world, Vec3::new(0.0, 0.0, 15.0), "Camera".to_string());
        world.resources.active_camera = Some(camera);
        spawn_sun(world);
    }
}

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

launch creates the window, initializes the wgpu renderer, runs initialize once, then drives the game loop and calls run_systems every frame. The camera sits at (0, 0, 15) looking toward the origin. A directional light is spawned so that the PBR materials have something to react to. The result is an empty scene with the engine's debug grid floor.

Step 2: Game constants and state

The game data goes in your state struct. The engine does not own any of it. Constants describe the arena and the speed of moving objects:

#![allow(unused)]
fn main() {
use nightshade::ecs::material::resources::material_registry_insert;
use nightshade::prelude::*;

const PADDLE_WIDTH: f32 = 0.3;
const PADDLE_HEIGHT: f32 = 2.0;
const PADDLE_DEPTH: f32 = 0.3;
const PADDLE_SPEED: f32 = 8.0;
const BALL_SIZE: f32 = 0.3;
const BALL_SPEED: f32 = 6.0;
const ARENA_WIDTH: f32 = 12.0;
const ARENA_HEIGHT: f32 = 8.0;
const WINNING_SCORE: u32 = 5;

#[derive(Default)]
struct PongGame {
    left_paddle_y: f32,
    right_paddle_y: f32,
    ball_x: f32,
    ball_y: f32,
    ball_vel_x: f32,
    ball_vel_y: f32,
    left_score: u32,
    right_score: u32,
    left_paddle_entity: Option<Entity>,
    right_paddle_entity: Option<Entity>,
    ball_entity: Option<Entity>,
    paused: bool,
    game_over: bool,
}
}

The fields fall into two groups. The first is the simulation state, positions and velocities and scores, all plain f32. The second is the bag of Option<Entity> handles that link game objects to their ECS-side render entity. The struct knows where the ball is mathematically. The ECS knows where the ball mesh is drawn. The sync pass is what closes the gap.

Step 3: Spawning game objects

The paddles, the ball, and the walls are each a mesh entity with a material. The helper spawn_colored_mesh wraps the engine's spawn_mesh with material registration so each call yields a single ready-to-render entity:

#![allow(unused)]
fn main() {
impl PongGame {
    fn create_game_objects(&mut self, world: &mut World) {
        self.left_paddle_entity = Some(self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(-ARENA_WIDTH / 2.0 + 0.5, 0.0, 0.0),
            Vec3::new(PADDLE_WIDTH, PADDLE_HEIGHT, PADDLE_DEPTH),
            [0.2, 0.6, 1.0, 1.0],
        ));

        self.right_paddle_entity = Some(self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(ARENA_WIDTH / 2.0 - 0.5, 0.0, 0.0),
            Vec3::new(PADDLE_WIDTH, PADDLE_HEIGHT, PADDLE_DEPTH),
            [1.0, 0.4, 0.2, 1.0],
        ));

        self.ball_entity = Some(self.spawn_colored_mesh(
            world,
            "Sphere",
            Vec3::zeros(),
            Vec3::new(BALL_SIZE, BALL_SIZE, BALL_SIZE),
            [1.0, 1.0, 1.0, 1.0],
        ));

        self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(0.0, ARENA_HEIGHT / 2.0 + 0.25, 0.0),
            Vec3::new(ARENA_WIDTH + 1.0, 0.5, 0.5),
            [0.5, 0.5, 0.5, 1.0],
        );

        self.spawn_colored_mesh(
            world,
            "Cube",
            Vec3::new(0.0, -ARENA_HEIGHT / 2.0 - 0.25, 0.0),
            Vec3::new(ARENA_WIDTH + 1.0, 0.5, 0.5),
            [0.5, 0.5, 0.5, 1.0],
        );
    }

    fn spawn_colored_mesh(
        &self,
        world: &mut World,
        mesh_name: &str,
        position: Vec3,
        scale: Vec3,
        color: [f32; 4],
    ) -> Entity {
        let entity = spawn_mesh(world, mesh_name, position, scale);

        let material_name = format!("mat_{}", entity.id);
        material_registry_insert(
            &mut world.resources.material_registry,
            material_name.clone(),
            Material {
                base_color: color,
                ..Default::default()
            },
        );

        if let Some(&index) = world
            .resources
            .material_registry
            .registry
            .name_to_index
            .get(&material_name)
        {
            world.resources.material_registry.registry.add_reference(index);
        }

        world.core.set_material_ref(entity, MaterialRef::new(material_name));
        entity
    }
}
}

spawn_mesh creates an entity with LOCAL_TRANSFORM, GLOBAL_TRANSFORM, and RENDER_MESH. The material is inserted into the global registry under a unique name derived from the entity id, then linked to the entity via MaterialRef. Material names are global, so using the entity id as the suffix avoids collisions when many entities share the spawn path.

Step 4: Ball movement and reset

The ball moves in a straight line until it hits something. Reset assigns a random angle within ninety degrees so the serve direction varies:

#![allow(unused)]
fn main() {
impl PongGame {
    fn reset_ball(&mut self) {
        self.ball_x = 0.0;
        self.ball_y = 0.0;
        let angle = (rand::random::<f32>() - 0.5) * std::f32::consts::PI * 0.5;
        self.ball_vel_x = BALL_SPEED * angle.cos();
        self.ball_vel_y = BALL_SPEED * angle.sin();
    }

    fn ball_movement_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        self.ball_x += self.ball_vel_x * dt;
        self.ball_y += self.ball_vel_y * dt;
    }

    fn normalize_ball_speed(&mut self) {
        let speed = (self.ball_vel_x * self.ball_vel_x + self.ball_vel_y * self.ball_vel_y).sqrt();
        self.ball_vel_x *= BALL_SPEED / speed;
        self.ball_vel_y *= BALL_SPEED / speed;
    }
}
}

world.resources.window.timing.delta_time is the duration of the last frame in seconds. Multiplying velocity by delta time before adding it to position is what makes movement frame-rate independent. A faster machine renders more frames with smaller deltas. A slower machine renders fewer frames with larger deltas. The ball moves the same distance per second on either.

Step 5: Input and AI

The player owns the left paddle. W and S, or the up and down arrow keys, move it. The AI tracks the ball's vertical position with a bit of dead zone so it does not jitter when the ball is already aligned:

#![allow(unused)]
fn main() {
impl PongGame {
    fn input_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        let keyboard = &world.resources.input.keyboard;

        if keyboard.is_key_pressed(KeyCode::KeyW) || keyboard.is_key_pressed(KeyCode::ArrowUp) {
            self.left_paddle_y += PADDLE_SPEED * dt;
        }
        if keyboard.is_key_pressed(KeyCode::KeyS) || keyboard.is_key_pressed(KeyCode::ArrowDown) {
            self.left_paddle_y -= PADDLE_SPEED * dt;
        }

        let max_y = ARENA_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
        self.left_paddle_y = self.left_paddle_y.clamp(-max_y, max_y);
    }

    fn ai_system(&mut self, world: &mut World) {
        let dt = world.resources.window.timing.delta_time;
        let distance = self.ball_y - self.right_paddle_y;

        if distance.abs() > 0.2 {
            self.right_paddle_y += distance.signum() * PADDLE_SPEED * 0.75 * dt;
        }

        let max_y = ARENA_HEIGHT / 2.0 - PADDLE_HEIGHT / 2.0;
        self.right_paddle_y = self.right_paddle_y.clamp(-max_y, max_y);
    }
}
}

is_key_pressed reports whether a key is currently held down. It is the polling form, not the event form, which is what we want for continuous motion. The AI runs at three quarters of the player's speed, which is enough handicap that a competent player can win.

Step 6: Collision detection

Three kinds of collision happen. The ball hits the top or bottom wall, in which case the Y velocity flips. The ball hits a paddle, in which case the X velocity flips and the Y velocity gets nudged based on where on the paddle the ball landed. The ball passes a paddle entirely, in which case the other side scores:

#![allow(unused)]
fn main() {
impl PongGame {
    fn collision_system(&mut self) {
        let ball_max_y = ARENA_HEIGHT / 2.0 - BALL_SIZE;
        if self.ball_y > ball_max_y {
            self.ball_y = ball_max_y;
            self.ball_vel_y = -self.ball_vel_y.abs();
        } else if self.ball_y < -ball_max_y {
            self.ball_y = -ball_max_y;
            self.ball_vel_y = self.ball_vel_y.abs();
        }

        let left_x = -ARENA_WIDTH / 2.0 + 0.5;
        if self.ball_x < left_x + PADDLE_WIDTH / 2.0 + BALL_SIZE
            && self.ball_x > left_x - PADDLE_WIDTH / 2.0
            && (self.ball_y - self.left_paddle_y).abs() < PADDLE_HEIGHT / 2.0 + BALL_SIZE
        {
            self.ball_x = left_x + PADDLE_WIDTH / 2.0 + BALL_SIZE;
            self.ball_vel_x = self.ball_vel_x.abs();
            let hit_offset = (self.ball_y - self.left_paddle_y) / (PADDLE_HEIGHT / 2.0);
            self.ball_vel_y += hit_offset * 2.0;
            self.normalize_ball_speed();
        }

        let right_x = ARENA_WIDTH / 2.0 - 0.5;
        if self.ball_x > right_x - PADDLE_WIDTH / 2.0 - BALL_SIZE
            && self.ball_x < right_x + PADDLE_WIDTH / 2.0
            && (self.ball_y - self.right_paddle_y).abs() < PADDLE_HEIGHT / 2.0 + BALL_SIZE
        {
            self.ball_x = right_x - PADDLE_WIDTH / 2.0 - BALL_SIZE;
            self.ball_vel_x = -self.ball_vel_x.abs();
            let hit_offset = (self.ball_y - self.right_paddle_y) / (PADDLE_HEIGHT / 2.0);
            self.ball_vel_y += hit_offset * 2.0;
            self.normalize_ball_speed();
        }

        if self.ball_x < -ARENA_WIDTH / 2.0 - 1.0 {
            self.right_score += 1;
            self.reset_ball();
            if self.right_score >= WINNING_SCORE {
                self.game_over = true;
            }
        } else if self.ball_x > ARENA_WIDTH / 2.0 + 1.0 {
            self.left_score += 1;
            self.reset_ball();
            if self.left_score >= WINNING_SCORE {
                self.game_over = true;
            }
        }
    }
}
}

The hit offset modifies the bounce angle so the player can steer the ball. A center hit barely changes the angle. An edge hit sends the ball off at a steeper angle. After modifying Y velocity, normalize_ball_speed rescales the velocity vector back to BALL_SPEED so the ball does not gradually accelerate.

The wall and paddle collisions overwrite the X or Y component to its absolute value rather than negating the current sign. The reason is that two-frame fast-moving balls can end up inside a wall on a single tick. Clamping the sign in one direction guarantees forward progress out of the wall on the next frame.

Step 7: Syncing visuals

Game state has changed. The ECS does not know yet. The sync pass writes the new positions into the local transform of each render entity and marks the transform dirty so the engine recomputes the global transform hierarchy:

#![allow(unused)]
fn main() {
impl PongGame {
    fn update_visuals(&mut self, world: &mut World) {
        if let Some(entity) = self.left_paddle_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.y = self.left_paddle_y;
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.right_paddle_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.y = self.right_paddle_y;
            }
            mark_local_transform_dirty(world, entity);
        }

        if let Some(entity) = self.ball_entity {
            if let Some(transform) = world.core.get_local_transform_mut(entity) {
                transform.translation.x = self.ball_x;
                transform.translation.y = self.ball_y;
            }
            mark_local_transform_dirty(world, entity);
        }
    }
}
}

mark_local_transform_dirty is the call that wires the local transform change into the engine's transform propagation pass. Without it, the global transform stays stale and the rendered mesh does not move. This is the only piece of the sync pass that is not a plain field write.

Step 8: The game loop

The trait implementation pulls all the systems together. initialize sets the window title, configures the camera, and spawns the scene. run_systems runs each frame in order. on_keyboard_input handles one-shot key events:

#![allow(unused)]
fn main() {
impl State for PongGame {
    fn initialize(&mut self, world: &mut World) {
        world.resources.window.title = "Pong".to_string();
        world.resources.graphics.atmosphere = Atmosphere::Space;
        world.resources.graphics.show_grid = false;
        world.resources.user_interface.enabled = true;

        spawn_sun_without_shadows(world);

        let camera = spawn_camera(world, Vec3::new(0.0, 0.0, 15.0), "Camera".to_string());
        if let Some(camera_component) = world.core.get_camera_mut(camera) {
            camera_component.projection = Projection::Perspective(PerspectiveCamera {
                aspect_ratio: None,
                y_fov_rad: 60.0_f32.to_radians(),
                z_far: Some(1000.0),
                z_near: 0.1,
            });
        }
        world.resources.active_camera = Some(camera);

        self.create_game_objects(world);
        self.reset_ball();
    }

    fn run_systems(&mut self, world: &mut World) {
        escape_key_exit_system(world);

        if !self.paused && !self.game_over {
            self.input_system(world);
            self.ai_system(world);
            self.ball_movement_system(world);
            self.collision_system();
        }

        self.update_visuals(world);
    }

    fn on_keyboard_input(&mut self, _world: &mut World, key: KeyCode, state: ElementState) {
        if state == ElementState::Pressed {
            match key {
                KeyCode::Space => self.paused = !self.paused,
                KeyCode::KeyR => self.reset_game(),
                _ => {}
            }
        }
    }
}
}

The order inside run_systems is input, AI, ball movement, collision, visual sync. The pause and game-over flags gate the simulation half but not the visual sync, so the game keeps rendering the static scene while paused. is_key_pressed is the right tool for held keys like W and S. on_keyboard_input is the right tool for one-shot keys like space and R, where the state should change once per press and not repeatedly while the key is held.

Step 9: Game reset

#![allow(unused)]
fn main() {
impl PongGame {
    fn reset_game(&mut self) {
        self.left_paddle_y = 0.0;
        self.right_paddle_y = 0.0;
        self.left_score = 0;
        self.right_score = 0;
        self.paused = false;
        self.game_over = false;
        self.reset_ball();
    }
}
}

Reset is trivial because the game state lives in plain fields. There is no ECS bookkeeping to undo. The render entities stay where they are. The next visual sync writes the zeroed positions into their transforms.

Key patterns demonstrated

PatternWhere Used
State trait lifecycleinitialize, run_systems, on_keyboard_input
Entity spawningspawn_mesh plus material registration
Frame-rate independent movementvelocity * delta_time
Input pollingkeyboard.is_key_pressed() for held keys
One-shot input eventson_keyboard_input for press and release
Transform updatesget_local_transform_mut and mark_local_transform_dirty
Game state separationLogic in struct fields, visuals in ECS

Where to go next

The foundation here generalizes. The same shape of state struct, systems list, and visual sync works for any small game. The next steps that extend it:

  • Physics. Replace the hand-written collision with Rapier rigid bodies and colliders. See Physics Overview.
  • Audio. Add sound effects on paddle hits and scores with AudioSource entities. See Audio System.
  • 3D models. Replace cubes with glTF models loaded via import_gltf_from_bytes. See Meshes & Models.
  • Particles. Add a spark burst on each paddle collision. See Particle Systems.
  • Materials. Give the ball an emissive material so it glows. See Materials.