Main Loop
The frame is the unit of work in Nightshade. Every frame runs the same sequence of steps in the same order, and almost all engine behavior is one of those steps. Understanding the order matters because anything you write either fits between two steps or replaces one.
Frame Execution Order
Each frame runs the following steps, in order:
1. Process window/input events (winit)
2. Update input state from events
3. Calculate delta time
4. Call State::run_systems(), which runs game logic
5. Dispatch EventBus messages
6. Run FrameSchedule, dispatching engine systems in order:
a. Initialize and update audio (if audio feature)
b. Update camera aspect ratios
c. Step physics simulation (if physics feature)
d. Update tweens
e. Update animation players
f. Apply animations to transforms
g. Propagate transform hierarchy
h. Update instanced mesh caches
i. Run retained UI systems (input sync, picking, layout, rendering)
j. Reset mouse, keyboard, and touch input state
k. Process deferred commands
l. Cleanup unused resources
7. Execute render graph passes
8. Present to swapchain
run_systems runs before the frame schedule. That means game logic sees the freshly updated input state, but it sees physics, animation, and transforms from the previous frame. Reads from the active camera's global transform inside run_systems will see last frame's value, and writes to local transforms will be propagated by step 6g of this frame.
Customizing the Schedule
The frame schedule is a resource at world.resources.frame_schedule. It can be edited from State::initialize to insert custom systems between engine systems, remove systems that the game does not need, or reorder dispatch.
#![allow(unused)] fn main() { fn initialize(&mut self, world: &mut World) { world.resources.frame_schedule.insert_after( system_names::RUN_PHYSICS, "my_gameplay_system", my_gameplay_system, ); world.resources.frame_schedule.remove(system_names::UI_LAYOUT_COMPUTE); } }
The full list of engine system name constants is in system_names in the prelude.
Timing
All timing information lives on world.resources.window.timing:
#![allow(unused)] fn main() { pub struct WindowTiming { pub frames_per_second: f32, pub delta_time: f32, pub raw_delta_time: f32, pub time_speed: f32, pub last_frame_start_instant: Option<web_time::Instant>, pub current_frame_start_instant: Option<web_time::Instant>, pub initial_frame_start_instant: Option<web_time::Instant>, pub frame_counter: u32, pub uptime_milliseconds: u64, } fn run_systems(&mut self, world: &mut World) { let dt = world.resources.window.timing.delta_time; let elapsed = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0; let frame = world.resources.window.timing.frame_counter; } }
delta_time is the scaled, time-speed-aware seconds since the previous frame. raw_delta_time is the same value before time_speed is applied. The two are different only when something has changed time_speed away from 1.0.
Fixed Timestep Physics
Variable frame rates produce jittery physics. The fix is to step physics at a fixed timestep regardless of the render frame rate, accumulating leftover time and stepping again on later frames if more than one step has elapsed.
#![allow(unused)] fn main() { const PHYSICS_TIMESTEP: f32 = 1.0 / 60.0; fn update_physics(world: &mut World, dt: f32) { world.resources.physics_accumulator += dt; while world.resources.physics_accumulator >= PHYSICS_TIMESTEP { store_physics_state(world); world.resources.physics.step(PHYSICS_TIMESTEP); world.resources.physics_accumulator -= PHYSICS_TIMESTEP; } let alpha = world.resources.physics_accumulator / PHYSICS_TIMESTEP; interpolate_physics_transforms(world, alpha); } }
The render is then drawn at the interpolated point between the previous and current physics states. The cost is one frame of input lag on collisions. The benefit is that a 240 Hz render and a 60 Hz render see the same physics behavior.
Physics Interpolation
Each physics body that needs smooth rendering carries a PhysicsInterpolation component holding the previous and current transforms, and the renderer lerps between them by alpha.
#![allow(unused)] fn main() { pub struct PhysicsInterpolation { pub previous_translation: Vec3, pub previous_rotation: Quat, pub current_translation: Vec3, pub current_rotation: Quat, } fn interpolate_physics_transforms(world: &mut World, alpha: f32) { for entity in world.core.query_entities(PHYSICS_INTERPOLATION) { let interp = world.core.get_physics_interpolation(entity).unwrap(); let translation = interp.previous_translation.lerp(&interp.current_translation, alpha); let rotation = interp.previous_rotation.slerp(&interp.current_rotation, alpha); } } }
System Ordering Within run_systems
Within run_systems, the order in which the application calls its own systems is the order they run. Input handling and movement code go before any system that depends on their results.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { handle_input(world); player_movement_system(world); ai_decision_system(world); } }
Reading physics contacts happens in run_systems of the next frame, since the physics step ran in step 6c of the previous frame.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let contacts = get_all_contacts(world); for contact in contacts { handle_collision(world, contact); } } }
Delta Time
Movement, animation, and any value that changes over time must be scaled by delta_time. Otherwise the speed of the change is tied to the frame rate, and the game runs faster on a 240 Hz monitor than on a 60 Hz one.
#![allow(unused)] fn main() { fn move_entity(world: &mut World, entity: Entity, velocity: Vec3) { let dt = world.resources.window.timing.delta_time; if let Some(transform) = world.core.get_local_transform_mut(entity) { transform.translation += velocity * dt; } } }
For periodic events, accumulate delta time into a per-game f32 and fire when the accumulator crosses a threshold.
#![allow(unused)] fn main() { struct MyGame { spawn_timer: f32, } fn run_systems(&mut self, world: &mut World) { let dt = world.resources.window.timing.delta_time; self.spawn_timer += dt; if self.spawn_timer >= 2.0 { spawn_enemy(world); self.spawn_timer = 0.0; } } }
Entry Points
The desktop entry point is nightshade::launch called from fn main.
fn main() -> Result<(), Box<dyn std::error::Error>> { nightshade::launch(MyGame::default()) }
The WASM entry point is the same call, awaited inside an async function exported with #[wasm_bindgen(start)].
#![allow(unused)] fn main() { #[wasm_bindgen(start)] pub async fn start() { nightshade::launch(MyGame::default()).await; } }
Diagnosing Frame Issues
A frame spike shows up as a single large delta_time. Logging the spike inside run_systems gives a coarse signal that something blocked the main thread.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let dt = world.resources.window.timing.delta_time; if dt > 0.1 { tracing::warn!("Long frame: {:.3}s", dt); } } }
For consistent slowdowns, wrap individual systems in Instant::now to see which one is eating the budget. The 16 ms target for 60 Hz divides across every system in run_systems plus the engine schedule plus the render graph, so finding which slice owns the time is the first step before optimizing anything.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { let start = std::time::Instant::now(); expensive_system(world); let elapsed = start.elapsed(); if elapsed.as_millis() > 5 { tracing::info!("expensive_system took {:?}", elapsed); } } }