The State Trait
State is the interface between a game and the engine. A game is a type that implements State, and the engine drives it. The trait has five methods, all with defaults, so a minimal game implements none of them and gets a blank window.
Trait Definition
#![allow(unused)] fn main() { pub trait State { fn initialize(&mut self, _world: &mut World) {} fn configure_render_graph( &mut self, graph: &mut RenderGraph<World>, device: &wgpu::Device, surface_format: wgpu::TextureFormat, resources: RenderResources, ) { /* default: bloom + post-processing + swapchain blit */ } fn run_systems(&mut self, _world: &mut World) {} fn pre_render(&mut self, _renderer: &mut WgpuRenderer, _world: &mut World) {} fn update_render_graph(&mut self, _graph: &mut RenderGraph<World>, _world: &World) {} } }
Window state (title, icon, log config, next state) lives on the world.resources.window resource. Input, file-drop, gamepad, and lifecycle events arrive on world.resources.input.events as AppEvent values, and the application drains them in run_systems.
Window Configuration
Title
The window title is a String on the Window resource. Set it in initialize and a sync system propagates the value to the OS window each frame, so writes at runtime also take effect.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.window.title = "My Awesome Game".to_string(); } }
Icon
The icon is Option<&'static [u8]> of PNG bytes. The default is the built-in Nightshade icon. Override it with include_bytes!, or set it to None to remove the icon entirely.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.window.icon_bytes = Some(include_bytes!("../assets/icon.png")); } }
The sync runs on desktop only and requires the assets feature.
Logging
File-based logging is configured through log_config: LogConfig on the Window resource. Logging is initialized before initialize runs, so the customization happens when building the initial Window, not inside the trait method.
#![allow(unused)] fn main() { let mut window = Window::default(); window.log_config = LogConfig { directory: "logs".to_string(), rotation: LogRotation::Daily, default_filter: "info".to_string(), timestamp_format: "%Y-%m-%d_%H-%M-%S".to_string(), }; }
LogRotation is one of PerSession (new file each launch), Daily, or Never (append to a single file). Desktop only, requires the tracing feature.
initialize
Called once at startup. This is where the initial scene, the active camera, and the application's own state get built.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.graphics.atmosphere = Atmosphere::Sky; let camera = spawn_camera(world, Vec3::new(0.0, 5.0, 10.0), "Camera".to_string()); world.resources.active_camera = Some(camera); self.player = Some(spawn_player(world)); } }
run_systems
Called every frame, after the engine has updated input and timing but before the frame schedule runs. Game logic goes here.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { player_movement_system(world); enemy_ai_system(world); collision_response_system(world); } }
Input and Lifecycle Events
The engine pushes input, file-drop, gamepad, and lifecycle events onto world.resources.input.events as AppEvent values. Drain the queue in run_systems. Events not drained on the frame they arrive are lost, since the engine clears the queue after run_systems returns.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let events = std::mem::take(&mut world.resources.input.events); for event in events { match event { AppEvent::Keyboard { key, state } if state == KeyState::Pressed => match key { KeyCode::Escape => self.paused = !self.paused, KeyCode::F11 => toggle_fullscreen(world), _ => {} }, AppEvent::Mouse { button, state } if state == ElementState::Pressed => match button { MouseButton::Left => self.shoot(world), MouseButton::Right => self.aim(world), _ => {} }, AppEvent::Gamepad(gp_event) => { if let gilrs::EventType::ButtonPressed(button, _) = gp_event.event { match button { gilrs::Button::Start => self.paused = !self.paused, gilrs::Button::South => self.player_jump(), _ => {} } } } AppEvent::FileDropped(file) => self.process_dropped_data(world, &file.name, &file.data), AppEvent::FileDroppedPath(path) => self.load_model(world, &path), AppEvent::FileHovered(_) => self.show_drop_indicator = true, AppEvent::FileHoverCancelled => self.show_drop_indicator = false, AppEvent::Suspended | AppEvent::Resumed => {} } } } }
configure_render_graph
Called once during initialization to build the render graph. The default configures bloom, post-processing, and a swapchain blit. Override it to add custom passes or replace the pipeline.
#![allow(unused)] fn main() { fn configure_render_graph( &mut self, graph: &mut RenderGraph<World>, device: &wgpu::Device, surface_format: wgpu::TextureFormat, resources: RenderResources, ) { let bloom_pass = passes::BloomPass::new(device, 1920, 1080); graph.add_pass( Box::new(bloom_pass), &[("input", resources.scene_color), ("output", resources.bloom)], ); } }
update_render_graph
Called every frame. Use it to toggle passes or update graph state in response to runtime changes.
#![allow(unused)] fn main() { fn update_render_graph(&mut self, graph: &mut RenderGraph<World>, world: &World) { if self.bloom_changed { graph.set_enabled("bloom_pass", self.bloom_enabled); self.bloom_changed = false; } } }
pre_render
Called before render graph execution each frame. The hook into the renderer is for custom GPU uploads and direct renderer state changes that need to happen before the passes run.
#![allow(unused)] fn main() { fn pre_render(&mut self, renderer: &mut WgpuRenderer, world: &mut World) { renderer.update_custom_buffer(world, &self.custom_data); } }
State Transitions
Setting world.resources.window.next_state inside run_systems switches the game to a new state at the end of the frame. The field holds a builder closure that receives &mut World, so the next state can be constructed against the live engine resources.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { if self.transition_to_gameplay { world.resources.window.next_state = Some(Box::new(|world| Box::new(GameplayState::from_world(world)))); } } }
Event Bus Messages
Custom EventBus messages live on world.resources.event_bus.messages. Drain that VecDeque in run_systems to process them. See the event system chapter for details.
Launching the Game
nightshade::launch runs the game. It takes a value of any type that implements State.
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MyGame::default()) }
A common pattern is to set up the initial scene inside initialize, including HDR skybox loads.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { load_hdr_skybox(world, include_bytes!("../assets/sky.hdr").to_vec()); } }