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 walks through building a complete 3D Pong game from scratch. By the end, you'll have two paddles, a bouncing ball, AI opponent, scoring, pause/unpause, and a game-over screen — all rendered in 3D with PBR materials and egui overlays.

Project Setup

Create a new project:

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"

Step 1: The Empty Window

Every Nightshade application starts with a struct that implements the State trait and a call to launch:

use nightshade::prelude::*;

struct PongGame;

impl State for PongGame {
    fn title(&self) -> &str {
        "Pong"
    }

    fn initialize(&mut self, world: &mut World) {
        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, calls initialize once, then runs the game loop calling run_systems every frame. The camera is positioned at (0, 0, 15) looking toward the origin, and we add a directional light so objects are visible.

Run it and you'll see an empty scene with a grid floor.

Step 2: Game Constants and State

Define the arena dimensions and game state. All game data lives in your state struct — the engine doesn't own any of it:

#![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 game state is separate from the ECS world. The ECS holds the visual entities (meshes, transforms, materials). Your struct holds game logic data (positions, velocities, scores). Each frame, you update game logic first, then sync the ECS transforms to match.

Step 3: Spawning Game Objects

Create the paddles, ball, and walls. Each is a mesh entity with a material:

#![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 components. The material is registered in the global MaterialRegistry by name, then assigned to the entity via MaterialRef. Each material needs a unique name — using the entity ID ensures no collisions.

Step 4: Ball Movement and Reset

The ball moves in a straight line, bouncing off walls and paddles:

#![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;
    }
}
}

Time comes from world.resources.window.timing.delta_time, which gives the frame duration in seconds. Multiplying velocity by delta time makes movement frame-rate independent.

Step 5: Input and AI

The player controls the left paddle with W/S or arrow keys. The AI tracks the ball's Y position:

#![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);
    }
}
}

Input is polled via world.resources.input.keyboard.is_key_pressed(). This checks whether a key is currently held down (not just pressed this frame).

Step 6: Collision Detection

Check ball against walls and paddles. When the ball passes a paddle's edge, score a point:

#![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;
            }
        }
    }
}
}

Where the ball hits the paddle affects the bounce angle — hitting the edge sends the ball at a steeper angle, hitting the center keeps it flat. After adjusting the velocity, normalize_ball_speed() ensures the ball always moves at BALL_SPEED.

Step 7: Syncing Visuals

After updating game logic, write the positions back to the ECS transforms. This is where game state becomes visible:

#![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 tells the engine that this entity's transform changed and the global transform hierarchy needs to be recalculated. Without it, the entity won't visually move.

Step 8: The Game Loop

Wire everything together in the State trait implementation:

#![allow(unused)]
fn main() {
impl State for PongGame {
    fn title(&self) -> &str {
        "Pong"
    }

    fn initialize(&mut self, world: &mut World) {
        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(),
                _ => {}
            }
        }
    }
}
}

run_systems is called every frame. The pattern is: check input → update game logic → detect collisions → sync visuals. on_keyboard_input handles one-shot key events (pressed/released) rather than held keys.

Step 9: UI Overlay with egui

Add score display and pause/game-over screens. The ui method receives an egui context for immediate-mode UI:

#![allow(unused)]
fn main() {
impl State for PongGame {
    fn ui(&mut self, _world: &mut World, ctx: &egui::Context) {
        egui::Window::new("Score")
            .anchor(egui::Align2::CENTER_TOP, [0.0, 10.0])
            .resizable(false)
            .collapsible(false)
            .title_bar(false)
            .show(ctx, |ui| {
                ui.heading(format!("{} - {}", self.left_score, self.right_score));
            });

        if self.paused {
            egui::CentralPanel::default()
                .frame(egui::Frame::new().fill(egui::Color32::from_black_alpha(180)))
                .show(ctx, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(100.0);
                        ui.heading("PAUSED");
                        ui.add_space(20.0);
                        ui.label("Press SPACE to resume");
                        ui.label("Press R to restart");
                    });
                });
        }

        if self.game_over {
            egui::CentralPanel::default()
                .frame(egui::Frame::new().fill(egui::Color32::from_black_alpha(180)))
                .show(ctx, |ui| {
                    ui.vertical_centered(|ui| {
                        ui.add_space(100.0);
                        let winner = if self.left_score >= WINNING_SCORE {
                            "You Win!"
                        } else {
                            "AI Wins!"
                        };
                        ui.heading(winner);
                        ui.add_space(10.0);
                        ui.label(format!("Final Score: {} - {}", self.left_score, self.right_score));
                        ui.add_space(20.0);
                        ui.label("Press R to play again");
                    });
                });
        }

        egui::Window::new("Controls")
            .anchor(egui::Align2::LEFT_BOTTOM, [10.0, -10.0])
            .resizable(false)
            .collapsible(false)
            .show(ctx, |ui| {
                ui.label("W/S or Up/Down - Move paddle");
                ui.label("SPACE - Pause");
                ui.label("R - Restart");
                ui.label("ESC - Exit");
            });
    }
}
}

egui runs at the end of each frame, after rendering. The anchor method positions windows relative to screen edges. CentralPanel covers the entire screen — useful for overlay menus.

Step 10: 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();
    }
}
}

Since game state lives in your struct (not the ECS), resetting is just zeroing your fields. The ECS entities remain — they just get new transform values next frame.

Key Patterns Demonstrated

PatternWhere Used
State trait lifecycleinitialize, run_systems, on_keyboard_input, ui
Entity spawningspawn_mesh + material registration
Frame-rate independent movementvelocity * delta_time
Input pollingkeyboard.is_key_pressed() for held keys
One-shot input eventson_keyboard_input for press/release
Transform updatesget_local_transform_mut + mark_local_transform_dirty
Game state separationLogic in struct fields, visuals in ECS
egui overlaysScore display, pause menu, game over screen

Where to Go Next

From this foundation you can add:

  • Physics: Replace manual collision with Rapier rigid bodies and colliders. See Physics Overview.
  • Audio: Add sound effects with AudioSource entities. See Audio System.
  • 3D Models: Replace cubes with loaded glTF models via import_gltf_from_bytes. See Meshes & Models.
  • Particles: Add spark effects on ball collision. See Particle Systems.
  • Materials: Make the ball emissive so it glows. See Materials.