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.
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.
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…
}
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:
// 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.
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,
AboutToWait → request_redraw() →
RedrawRequested → update() + render().
One loop, compiled for two worlds.