Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Fixed-Step Simulation and Variable-Rate Rendering Loop

Hello! Last lesson gave your sandbox a Win32 window, a non-blocking message pump, and engine-owned window events. Its 16 ms sleep was only temporary: sleeping does not tell you how much time actually passed, and it does not separate simulation from drawing.

In this lesson, you’ll replace that idle loop with a measured fixed-step simulation, variable-rate rendering loop. You’ll also put the scheduling logic in a small, testable engine class. Allow about 45–55 minutes, including the resources and implementation. This timing boundary will eventually serve movement, agents, and other systems that must behave consistently even when rendering becomes expensive.


Two clocks, two responsibilities

A rendered frame happens whenever the application finishes its work and is ready to draw again. A simulation tick represents a chosen amount of game time—say, second. Those rates need not match. A fast machine may render several times before another tick is due; a slow frame may require several ticks before the next render.

Watch Vittorio Romeo’s “Getting The Game Loop Right” for a visual account of both cases. Treat its tick-rate example as an illustration, not a rate to use in the engine.

Getting The Game Loop Right

Vittorio Romeo’s “Getting The Game Loop Right” makes the distinction between display frames and simulation ticks visible before presenting the loop.

Watch the tick examples for frames that arrive both faster and slower than the chosen update rate. Then watch the accumulator and identify what remains after each full tick has been consumed.

We measure frame time from successive readings of std::chrono::steady_clock. That measured duration is not itself a simulation step. Instead, we add it to an accumulator. Each time the accumulator contains a full tick, we update with the same fixed duration and subtract that duration. Any fraction of a tick stays available for the next frame.

For example, with a 10 ms tick, a measured 24 ms permits two 10 ms updates and leaves 4 ms in the accumulator. Rendering still happens once on that outer-loop iteration. Feeding 24 ms directly to one simulation update would be a different, variable-step design.

Glenn Fiedler’s “Fix Your Timestep!” develops this approach and examines its failure mode under load. Read the indicated portions before setting the scheduler’s limits.

Fix Your Timestep! | Gaffer On Games

Read Glenn Fiedler’s “Fix Your Timestep!” for the accumulator model, the cost of trying to catch up indefinitely, and a way to draw between simulation states.

Under “Semi-fixed timestep,” start at “What is the spiral of death?” and read the overload case. Under “Free the physics,” start at “What we want then is the best of both worlds” and read the accumulator explanation. Finally, under “The final touch,” start at “Now consider that the majority of render frames” and read the interpolation argument, including its code example.

A fixed timestep makes update behavior less dependent on rendering speed; it does not by itself guarantee collision accuracy or reproducibility. Inputs, random-number use, floating-point behavior, and update order still matter. For your later seeded-world systems, this loop establishes a useful timing contract, not a complete determinism solution.


Build a scheduler you can test without a window

The Win32 sandbox will supply measured seconds. Put the rule for consuming those seconds in engine/include/engine/FixedStepScheduler.hpp. Keeping it independent of both Win32 and the clock lets automated tests provide exact durations.

#pragma once

#include <algorithm>
#include <cmath>
#include <stdexcept>

namespace engine {

struct FixedStepResult {
    int updates = 0;
    double alpha = 0.0;
    double dropped_seconds = 0.0;
};

class FixedStepScheduler {
public:
    static constexpr double step_seconds = 1.0 / 60.0;
    static constexpr double max_frame_seconds = 0.25;
    static constexpr int max_updates_per_frame = 5;

    template <class Update>
    FixedStepResult advance(double elapsed_seconds, Update&& update) {
        if (!std::isfinite(elapsed_seconds) || elapsed_seconds < 0.0) {
            throw std::invalid_argument("Invalid elapsed time");
        }

        FixedStepResult result{};
        const double accepted =
            std::min(elapsed_seconds, max_frame_seconds);

        result.dropped_seconds = elapsed_seconds - accepted;
        accumulator_ += accepted;

        while (accumulator_ >= step_seconds &&
               result.updates < max_updates_per_frame) {
            update(step_seconds);
            accumulator_ -= step_seconds;
            ++result.updates;
        }

        // Do not let a sustained overload demand ever more catch-up work.
        if (accumulator_ >= step_seconds) {
            const double remainder =
                std::fmod(accumulator_, step_seconds);
            result.dropped_seconds += accumulator_ - remainder;
            accumulator_ = remainder;
        }

        result.alpha =
            std::clamp(accumulator_ / step_seconds, 0.0, 1.0);
        return result;
    }

private:
    double accumulator_ = 0.0;
};

} // namespace engine

Add the header to the Engine target’s public-header list if your CMake target lists headers explicitly. It needs no new .cpp file or Windows library.

There are two deliberate overload policies here. A measured gap longer than 250 ms is clamped before entering the accumulator; returning from a breakpoint should not demand seconds of immediate simulation. Separately, no outer frame executes more than five updates. If time is still owed after those five, the scheduler discards whole overdue ticks but retains a fractional remainder. dropped_seconds reports the time sacrificed by either policy.

That trade-off is visible: under sustained overload, game time slows relative to wall-clock time. The alternative—unbounded catch-up—can prevent the engine from rendering or responding promptly at all. Neither policy substitutes for measuring and reducing expensive work later.

The scheduler returns alpha, the fraction of a tick left in the accumulator. It does not pass that fraction to update. Every simulation call receives exactly step_seconds.


Replace the sandbox’s temporary idle loop

Use a one-dimensional moving position as a visible-in-the-logs stand-in for a future scene. You need no renderer or 3D mathematics yet. Store both the position before the latest tick and the position after it; drawing can blend those two values without changing simulation state.

In the sandbox’s existing main, retain its surrounding error handling and replace the temporary while loop. Add <chrono>, <string>, <thread>, and the scheduler header as needed. The following is the body of the existing try block:

engine::Window window(1280, 720, L"Engine Sandbox");
engine::FixedStepScheduler scheduler;

using Clock = std::chrono::steady_clock;
auto last_sample = Clock::now();
auto last_report = last_sample;

double previous_x = 0.0;
double current_x = 0.0;
bool running = true;

while (running) {
    const bool os_running = window.pump_messages();

    engine::WindowEvent event{};
    while (window.poll_event(event)) {
        switch (event.type) {
        case engine::WindowEvent::Type::CloseRequested:
            window.close();
            running = false;
            break;

        case engine::WindowEvent::Type::Resized:
            // A future renderer will use the new client-area dimensions.
            break;

        case engine::WindowEvent::Type::FocusChanged:
            // A future input layer will use this focus change.
            break;
        }
    }

    if (!os_running || !running) {
        break; // Do not update or draw after the window closes.
    }

    const auto now = Clock::now();
    const double frame_seconds =
        std::chrono::duration<double>(now - last_sample).count();
    last_sample = now;

    const auto result = scheduler.advance(
        frame_seconds,
        [&](double fixed_seconds) {
            previous_x = current_x;
            current_x += 2.0 * fixed_seconds; // 2 units per game second
        });

    // Rendering reads a presentation value; it does not update current_x.
    const double visible_x =
        previous_x + (current_x - previous_x) * result.alpha;

    // No graphics backend exists yet. Log a sample about once per second
    // rather than producing a log entry for every rendered frame.
    if (now - last_report >= std::chrono::seconds(1)) {
        engine::log(
            engine::LogChannel::Platform,
            engine::Severity::Info,
            "frame_ms=" + std::to_string(frame_seconds * 1000.0) +
            " ticks=" + std::to_string(result.updates) +
            " visible_x=" + std::to_string(visible_x) +
            " dropped_s=" + std::to_string(result.dropped_seconds));
        last_report = now;
    }

    // Placeholder for a variable-rate render(visible_x).
    // This short idle avoids a tight spin while there is no renderer.
    std::this_thread::sleep_for(std::chrono::milliseconds(1));
}

The timestamp is taken after pumping and handling window events. The duration since last_sample therefore includes the previous iteration’s work and the current iteration’s message handling. It measures elapsed wall-clock time, not merely the time spent inside update. When you later have rendering and presentation, their time will naturally be reflected in the next sample.

The 1 ms sleep is only a courtesy while drawing is a placeholder. It is not a promise to render at a particular frequency, and Windows may sleep longer. The next measured frame time accounts for however long it actually slept. Crucially, the old “sleep for 16 ms, then update once” dependency is gone.

Some outer iterations will run zero updates; others may run several. Each iteration that remains active reaches the render placeholder once. That is the separation: simulation frequency is governed by accumulated time, while rendering frequency follows how often the outer loop can run.

Why blend the position?

Suppose the latest completed tick moved current_x away from previous_x, but only half of another tick’s time has accumulated. Then alpha is approximately , and visible_x lies halfway between those stored positions. This can smooth a future drawing pass without moving the authoritative simulation position.

This form of interpolation displays a state slightly behind the latest simulated time; it never guesses the outcome of a tick that has not happened. Keep previous_x and current_x initialized alike so that drawing before the first tick is well-defined. Later, newly spawned or teleported objects will similarly need their presentation history reset.


Check the timing contract

A scheduler test is more reliable than trying to infer exact tick counts from a live Windows run. Add a test to your existing automated-test executable using its current assertion style. The essential cases can be written without a window:

#include <engine/FixedStepScheduler.hpp>

#include <cassert>
#include <cmath>

engine::FixedStepScheduler scheduler;
int total_ticks = 0;

auto tick = [&](double dt) {
    assert(std::abs(
        dt - engine::FixedStepScheduler::step_seconds) < 1e-12);
    ++total_ticks;
};

const double step = engine::FixedStepScheduler::step_seconds;

const auto first = scheduler.advance(step * 0.5, tick);
assert(first.updates == 0);
assert(std::abs(first.alpha - 0.5) < 1e-12);

const auto second = scheduler.advance(step * 0.5, tick);
assert(second.updates == 1);
assert(total_ticks == 1);

const auto third = scheduler.advance(step * 2.0, tick);
assert(third.updates == 2);
assert(total_ticks == 3);

const auto spike = scheduler.advance(1.0, tick);
assert(spike.updates ==
       engine::FixedStepScheduler::max_updates_per_frame);
assert(spike.dropped_seconds >= 0.75);

The last case does not claim that one wall-clock second was simulated. It checks precisely the opposite: the large gap is bounded, and lost time is reported.

Build and run the automated tests, then run the sandbox. Look for changing frame_ms values while ticks remains a small integer and the reported position progresses at roughly two units per simulated second. Resize and close the window to make sure message handling still works. Avoid treating the occasional printed visible_x as a precision timing benchmark: the once-per-second log and operating-system scheduling are not exact clocks.

If you later observe repeated nonzero dropped_s values during ordinary play, investigate update cost rather than raising the update limit blindly. A fixed timestep specifies how the simulation advances; it cannot make work that exceeds the machine’s time budget disappear.


Takeaways

Your sandbox now measures elapsed time with a steady clock, advances simulation only in fixed -second ticks, and reaches a rendering point at a rate independent of those ticks. The accumulator preserves partial ticks, interpolation provides a read-only presentation value, and bounded catch-up prevents a long stall from trapping the engine in updates.

Next, you’ll build an input layer that turns keyboard and mouse activity into engine-owned state. With the loop in place, that layer can define when input is gathered and when simulation ticks consume it—without exposing Win32 messages to gameplay code.

Can't find a good explanation? Sign up and we'll make it for you

Sign up