Concept · Building the World

The Game Loop

Strip away the terrain, the plants, the renderer, and what remains of any real-time engine is a single loop that never stops: read input, advance the world by a sliver of time, draw it, repeat. This page is about that loop as an idea — why it polls instead of waits, what dt is, and the quiet discipline that keeps simulation and drawing from tangling.

A program that never finishes

Most programs are scripts in spirit: they do their job and stop, or they sleep until you click something. A game can't do either, because its world keeps moving whether or not you touch it — clouds drift, the sun sets, rivers flow. So the program becomes a loop, and each lap of the loop (a frame, or a tick) produces one complete picture of the world.

The strange consequence is that nothing on screen persists. The world is redrawn from scratch every frame, and motion is just the difference between one frame and the next. A tree swaying in the wind isn't animated by some timer attached to the tree; it is simply drawn at a slightly different angle on every pass through the loop, 60 or more times a second, and your eye supplies the movement.

Poll, don't wait

Window systems are built for event-driven programs — ones that sleep until the user does something. A game inverts that: it asks for the next frame the moment the current one is finished. FloraForge's window library, winit, calls this poll mode, and the loop's heartbeat is a tidy two-step: when winit announces it has run out of events (AboutToWait), the engine requests a redraw; when the redraw arrives (RedrawRequested), it runs one update() and one render(). Then the cycle starts over.

src/app/event_loop.rs — the loop's heartbeat (trimmed)
event_loop.run(move |event, target| {
    target.set_control_flow(ControlFlow::Poll);

    match event {
        Event::WindowEvent { window_id, event } if window_id == app.window.id() => {
            // …keyboard and mouse events are routed to egui or the camera…
            match event {
                WindowEvent::RedrawRequested => {
                    app.update();
                    match app.render() {
                        Ok(()) => {}
                        Err(SurfaceError::Lost) => app.resize(app.gpu.size),
                        Err(SurfaceError::OutOfMemory) => target.exit(),
                        Err(SurfaceError::Timeout | SurfaceError::Outdated) => {}
                        Err(e) => log::error!("surface error: {e}"),
                    }
                }
                _ => {}
            }
        }
        Event::AboutToWait => {
            app.window.request_redraw();
        }
        _ => {}
    }
})?;

That's the whole engine, from a distance: one update(), one render(), forever. Input doesn't get a step of its own — key and mouse events arrive between frames and are stashed as state ("the W key is currently held") that the next update() reads.

dt: how the simulation survives any frame rate

Poll mode means the loop runs as fast as the machine allows, and that speed is never constant — a laptop on battery, a heavy view, a background download all change it. If the camera moved a fixed distance per frame, the world would literally run faster on faster computers, the bug that plagued early PC games.

The fix is one measurement at the top of every update: dt (delta time), the wall-clock time since the previous frame. Everything that moves is expressed as a rate — metres per second, radians per second — and multiplied by dt. Cover a frame in 7 ms or 33 ms, the camera still flies at the same metres per second.

src/app/mod.rs — measuring dt at the top of update()
fn update(&mut self) {
    let now = Instant::now();
    let dt = now.duration_since(self.last_frame).as_secs_f32();
    self.last_frame = now;

    self.frame_time_ms = self.frame_time_ms * 0.94 + (dt * 1000.0) * 0.06;
    self.elapsed_seconds += dt;

    // …branch on the current screen, then advance the world by dt…
}
144 fps — update(dt) runs 29 times each step: dt ≈ 7 ms 30 fps — update(dt) runs 6 times each step: dt ≈ 33 ms same world time, same camera position position += speed × dt
The same 200 ms of world time on two machines. The fast one slices it into many small dt steps, the slow one into a few big ones — but because every rate is multiplied by dt, both arrive at the same place. A slow machine just shows you the same world less often.

Update, then render — and never the twain

Inside one lap, the work splits into two halves with deliberately different personalities. Update is the think phase: it moves the camera, advances the day/night clock, streams chunks in and out, grows plants — pure logic that mutates the state of the world, caring only about dt. Render is the draw phase: it reads that state and turns it into GPU commands, changing nothing. It is the only half that ever touches the swapchain.

The split buys two things. First, correctness: the simulation behaves identically however fast the graphics run, because update never depends on how the world looks. Second, debuggability — a rule of thumb worth memorising: if something moves wrong, the bug is in update; if something looks wrong, the bug is in render. The two halves almost never tangle, so the rule almost never lies.

What FloraForge's update() and render() actually do, stage by stage — the streaming, the render passes, the loading pipeline — is the subject of its own illustrated tour: Inside the Engine. This page stays with the loop itself.

Fixed vs variable timestep

Measuring dt every lap gives a variable timestep: smooth, simple, and what FloraForge uses for normal play. Its weakness is reproducibility. Because dt jitters from frame to frame, no two runs take the same sequence of steps, and tiny floating-point differences compound — two flights through the world will never match exactly. Physics-heavy games suffer worse: a huge dt after a hitch can step a fast object clean through a wall.

The alternative is a fixed timestep: pretend exactly the same dt elapsed every frame, regardless of the clock. The simulation becomes deterministic — same inputs, same steps, same world — which is precisely what a benchmark needs. FloraForge's FPS benchmark replays a scripted camera flythrough on a fixed dt of 1/60 s, so the workload is identical on every run and only the measured wall-clock frame time varies:

src/app/mod.rs — benchmark mode swaps in a fixed timestep
// In benchmark mode, drive the camera from the script on a fixed timestep
// so the simulated workload is identical across runs; the real wall-clock
// `dt` is what we record. Otherwise sim_dt == dt and behavior is unchanged.
let sim_dt = if let Some(mut bench) = self.benchmark.take() {
    bench.tick(dt * 1000.0, &mut self.camera, &mut self.camera_controller);
    let fixed = bench.fixed_dt();
    self.benchmark = Some(bench);
    fixed
} else {
    dt
};

Notice the two clocks living side by side: the world advances by sim_dt (fixed, deterministic), while the report records the real dt (variable, the thing being measured). Many engines go further and run a fixed-step simulation under a variable-rate renderer at all times; FloraForge keeps it simple — variable for play, fixed for measurement.

One loop, five screens

A subtlety that surprises newcomers: the menu is the game loop too. FloraForge has no separate "menu mode" — a small state machine (StartMenu, Loading, Playing, Herbarium, PlantEditor) decides what update() and render() mean this frame, and everything shares the one code path. The payoff shows during loading: rather than freezing the window while the procedurally generated world is built, the work is split into phases that each run on their own lap of the loop — so the progress bar keeps animating, because the loop never stopped.

In the engine
The loop in src/app/event_loop.rs exists twice — once for native desktop and once for the browser build, where winit's event_loop.spawn() hands control back to the browser between laps. Both versions share the same skeleton: Poll mode, AboutToWaitrequest_redraw()RedrawRequestedupdate() + render(). One loop, compiled for two worlds.