A fixed-timestep game loop in Java updates your simulation at a constant rate regardless of frame rate, preventing physics bugs and non-deterministic behavior across different machines. This guide shows you how to build a correct accumulator-based loop, why volatile and synchronized matter for shared game state, and what breaks when you skip either safeguard.

What Is a Fixed-Timestep Game Loop?

A fixed-timestep game loop advances your game simulation by a constant time delta, such as 16 milliseconds, on every update call, regardless of how long the previous frame took to render. The simulation speed stays consistent whether the machine runs the update in 2ms or 14ms. This is the definition that matters for correctness.

The contrast with variable delta-time loops is direct. A variable loop measures how long the last frame took and passes that duration into the physics and logic code. On a fast machine, frames are short and updates are frequent. On a slow machine, frames are long and updates are coarse. Physics integrators, collision responses, and AI state machines can all produce different results depending on the delta size. That difference is the problem fixed timestep solves.

Determinism matters in practice. If your game runs identically on two machines with different hardware, you can reproduce bugs, record and replay inputs, and build multiplayer consistency on top of the same simulation. None of that is possible when update behavior varies with frame rate. The core challenge is that render frames and simulation steps run at different rates, so the loop must decouple them cleanly.

The Accumulator Pattern: How It Works

The accumulator pattern decouples simulation updates from rendering frequency. The accumulator is a variable that tracks real elapsed time that has not yet been consumed by fixed-update steps. Each frame, you add the elapsed real time to the accumulator, then drain it in fixed-size chunks by running update steps until the remainder is smaller than one fixed step.

The Canonical Loop Structure in Java

The steps below describe the accumulator loop. Each one maps directly to a line in the implementation that follows.

  1. Record the current time using System.nanoTime() at the start of each frame.
  2. Compute elapsed time as the difference between the current time and the previous frame’s recorded time.
  3. Cap elapsed time to a maximum value to prevent the spiral of death.
  4. Add the capped elapsed time to the accumulator variable.
  5. While the accumulator holds at least one fixed timestep, run one update step and subtract the timestep from the accumulator.
  6. Compute the interpolation alpha as the accumulator remainder divided by the fixed timestep.
  7. Render using interpolated state derived from the alpha value.

Use System.nanoTime() rather than System.currentTimeMillis() for all timing in a game loop. On Windows, currentTimeMillis() has a granularity of roughly 15ms, which is coarser than a 16ms timestep and makes delta-time measurements unreliable. System.nanoTime() provides nanosecond-resolution monotonic time that does not depend on the system clock. It does not give you a wall-clock time, but you don’t need one. You need elapsed duration. That’s what nanoTime() delivers.

The Spiral of Death

The spiral of death happens when update steps consistently take longer than the fixed timestep. Suppose your fixed step is 16ms but each update call takes 20ms. The accumulator grows faster than it drains, so the loop runs more and more update steps per frame, which makes each frame slower, which grows the accumulator further. The game freezes.

The fix is a cap on elapsed time. If a frame takes longer than, say, 250ms, you clamp it to 250ms before adding it to the accumulator. You lose simulation time during that slow frame, but the game stays responsive. Four dropped update steps are better than a lockup.

Threading Model: Single Thread vs Dedicated Thread

The JVM (Java Virtual Machine) is the runtime that executes your compiled Java bytecode. Within it, you have two main options for structuring a game loop: run everything on one thread, or split update logic onto a dedicated thread separate from rendering.

Single-Thread Loop

The single-thread approach runs the accumulator loop, the update calls, and the render calls sequentially on one thread. There’s no shared mutable state between threads, so there are no synchronization bugs. The loop is simpler to reason about and debug. The trade-off is that update and render cannot overlap in time, so a slow render call delays the next update.

Dedicated Update Thread with Runnable

The dedicated thread approach runs the fixed-timestep accumulator loop on a separate thread, letting the main thread handle rendering or UI. This decouples update timing from render timing and allows both to run in parallel. The cost is that any game state read by the render thread and written by the update thread must be handled carefully to avoid visibility and atomicity bugs.

Use Runnable rather than extending Thread for the game loop. Extending Thread ties your game loop logic to a specific thread implementation, prevents you from passing the loop to an ExecutorService, and uses up your single inheritance slot. A Runnable separates the what from the how. You can pass it to a plain Thread, a thread pool, or a virtual thread (introduced in Java 21) without changing the loop code.

Here is a working fixed-timestep loop using a dedicated thread and Runnable, targeting Java 11 and above:


import java.util.concurrent.atomic.AtomicBoolean;

public class GameLoop implements Runnable {

    private static final long FIXED_STEP_NS = 16_666_667L; // ~60 updates per second
    private static final long MAX_ELAPSED_NS = 250_000_000L; // cap to prevent spiral of death

    // volatile ensures the render thread always sees the latest position values
    private volatile double currentX;
    private volatile double currentY;
    private volatile double previousX;
    private volatile double previousY;

    // AtomicBoolean for the running flag avoids a data race on the boolean itself
    private final AtomicBoolean running = new AtomicBoolean(false);

    @Override
    public void run() {
        running.set(true);
        long previousTime = System.nanoTime(); // use nanoTime for monotonic, high-resolution timing
        long accumulator = 0L;

        while (running.get()) {
            long currentTime = System.nanoTime();
            long elapsed = currentTime - previousTime;
            previousTime = currentTime;

            // cap elapsed to avoid the spiral of death on slow frames
            if (elapsed > MAX_ELAPSED_NS) {
                elapsed = MAX_ELAPSED_NS;
            }

            accumulator += elapsed;

            while (accumulator >= FIXED_STEP_NS) {
                // save previous state before updating, needed for interpolation
                previousX = currentX;
                previousY = currentY;
                update(); // advance simulation by one fixed step
                accumulator -= FIXED_STEP_NS;
            }

            // alpha is the fraction of a timestep represented by the remaining accumulator
            double alpha = (double) accumulator / FIXED_STEP_NS;
            render(alpha);
        }
    }

    private void update() {
        // your simulation logic here — moves entities, runs physics, checks collisions
        currentX += 2.0;
    }

    private void render(double alpha) {
        // interpolate between previous and current state for smooth visuals
        double renderX = previousX + (currentX - previousX) * alpha;
        double renderY = previousY + (currentY - previousY) * alpha;
        // pass renderX and renderY to your drawing code
    }

    public void stop() {
        running.set(false); // signal the loop thread to exit cleanly
    }
}
    

Start the loop by passing an instance to a Thread and calling start(). Stop it by calling stop() and then join() on the thread to wait for it to exit cleanly before releasing resources.

Thread Safety for Shared Game State

Thread visibility is the core concurrency problem in a multi-threaded game loop. Without explicit synchronization, the JVM may cache variable values in a thread-local CPU register or cache. The render thread can read a value that the update thread wrote several frames ago. The game looks fine on your dev machine and breaks on a different CPU architecture. This is not hypothetical.

When volatile Is Sufficient

The volatile keyword, available since Java 5 via JSR-133, guarantees that reads and writes to a variable go directly to main memory rather than a thread-local cache. Any write to a volatile variable happens-before any subsequent read of that variable by another thread. That guarantee is defined by the Java Memory Model (JMM).

Use volatile when you have a single writer and a single reader, and when the variable is updated independently of other variables. A boolean running flag is the textbook case. A single position value that the update thread writes and the render thread reads also qualifies. The code example above uses volatile on currentX, currentY, previousX, and previousY for exactly this reason.

Try this: Remove the volatile keyword from currentX in the example above and run the loop under load. On a multi-core machine, you’ll likely observe the render thread reading stale position values, causing visual glitches or incorrect interpolation output. The bug may not appear on every run, which is what makes it dangerous.

When synchronized Is Required

volatile does not give you atomicity across multiple variables. If the update thread writes currentX and then currentY as two separate operations, the render thread can read the new currentX and the old currentY between those two writes. That’s a torn read. For entities with compound state, you need synchronized.

A synchronized block acquires a lock before executing and releases it after. Any thread that tries to enter a block synchronized on the same object must wait. This gives you atomicity: the render thread either reads both the new x and y, or both the old x and y. Never a mix.


private final Object stateLock = new Object();
private double currentX;
private double currentY;

// Called by the update thread
private void updatePosition(double newX, double newY) {
    synchronized (stateLock) {
        currentX = newX; // write both values under the same lock
        currentY = newY;
    }
}

// Called by the render thread
public double[] getPosition() {
    synchronized (stateLock) {
        return new double[]{currentX, currentY}; // read both values atomically
    }
}
    

The double-buffer pattern is a cleaner alternative for high-frequency state sharing. The update thread writes to a back-buffer object. The render thread reads from a front-buffer object. At the end of each update cycle, you swap the references atomically using an AtomicReference. This eliminates lock contention in the render path at the cost of extra object allocation.

Render Interpolation for Smooth Output

Interpolation solves a real visual problem. At a fixed update rate of 60Hz, a monitor running at 144Hz will repeat the same rendered frame multiple times between simulation steps without interpolation. Movement looks choppy even though the simulation is running correctly. The alpha value from the accumulator remainder is what you use to fix this.

The interpolation formula in Java is straightforward:


double renderX = previousX + (currentX - previousX) * alpha;
    

When alpha is 0.0, you render the previous state. When alpha is 1.0, you render the current state. At 0.5, you render halfway between. This produces smooth motion at any render rate without changing the simulation at all. The simulation stays deterministic. Only the visual output interpolates.

Interpolation requires storing both the previous and current state for every rendered entity. That’s extra memory, and it adds one fixed-step’s worth of visual lag. For fast-action games with smooth motion, the trade-off is worth it. For turn-based games or simulations where you don’t care about sub-frame smoothness, skip it and render the current state directly.

Common Mistakes and How to Diagnose Them

  • Using System.currentTimeMillis() for delta time. On Windows, this clock has roughly 15ms granularity. A 16ms fixed step measured with a 15ms-resolution clock produces wildly inconsistent deltas. Switch to System.nanoTime().
  • Forgetting to cap the accumulator. A single slow frame, such as one caused by garbage collection, can push the accumulator high enough to trigger dozens of update steps in the next frame. Cap elapsed time to 250ms or a similar reasonable maximum.
  • Sharing mutable entity state without volatile or synchronized. The render thread will read stale values. The bug is intermittent and hardware-dependent, which makes it hard to reproduce. Declare shared state volatile or protect compound updates with synchronized.
  • Calling Thread.sleep(1000/60) without accounting for update time. If your update and render together take 4ms and you then sleep for 16ms, your actual frame rate is lower than 60Hz. Measure the time spent in update and render, then sleep only for the remaining time in the frame budget.
  • Putting rendering inside the fixed-update step. Rendering should always use the interpolated state computed from the accumulator remainder. Rendering inside the update step ties visual output to simulation rate and defeats the purpose of the pattern.

Sleep Imprecision and Timer Drift

Thread.sleep(n) on most JVMs and operating systems sleeps for at least n milliseconds, often longer. A sleep of 16ms can return after 18ms or 20ms on a loaded system. Over hundreds of frames, this drift accumulates. The compensation pattern is to measure actual sleep duration with System.nanoTime() before and after the sleep call, then subtract the overshoot from the next sleep target.

For high-precision timing, some game loops use a busy-wait: spin in a while loop checking System.nanoTime() until the target time arrives. This is accurate but burns CPU continuously. A practical middle ground is to sleep for most of the remaining frame time, then busy-wait for the final millisecond. You get near-perfect frame timing without a full busy-wait CPU cost.

Java 21 virtual threads are not a solution to sleep imprecision. Virtual threads (introduced in Java 21) improve throughput for I/O-bound workloads by parking cheaply on blocking calls. Game loop timing precision is a hardware scheduler constraint, not a threading model issue. A virtual thread sleeping for 16ms faces the same OS scheduler granularity as a platform thread.

FAQ: Java Game Loop Threading

Why use volatile instead of synchronized in a Java game loop?

Use volatile when you have a single writer updating one variable and a single reader consuming it. It’s cheaper than synchronized because it doesn’t acquire a lock. Use synchronized when multiple variables must be updated atomically or when multiple threads write to the same state.

What causes jitter in a Java game loop?

Jitter comes from inconsistent frame timing. Common causes are Thread.sleep imprecision, garbage collection pauses, and missing accumulator caps. Measuring frame timestamps with System.nanoTime() and logging the deltas will show you which cause dominates in your loop.

Is Thread or Runnable better for a game loop in Java?

Runnable is the right choice. It separates your loop logic from the thread lifecycle, keeps your class free to extend other types, and lets you pass the loop to any executor. You can always wrap a Runnable in a Thread when you need direct lifecycle control.

What happens if I forget volatile on my running flag?

The loop thread may cache the flag value and never see the update that sets it to false. The loop runs forever even after you call stop(). The bug is more likely on multi-core machines where threads run on separate CPU cores with separate caches.

Should I use a fixed timestep or variable delta time for my Java 2D game?

Use fixed timestep when your game has physics, collision detection, or any logic where determinism matters. Use variable delta time for simple games where simulation correctness across machines is not a concern and you want the simplest possible loop. Fixed timestep is the right default for anything beyond basic movement.

Putting It Into Practice

The implementation in this guide gives you a working starting point. Copy the GameLoop class, wire it to your entity state, and run it. Then intentionally remove the volatile keyword from one position variable and observe what happens under load. That experiment will make the Java Memory Model concrete in a way that reading about it cannot.

To validate your loop’s timing, log frame timestamps to a file using System.nanoTime() at the start of each update step. Load the log and compute the delta between consecutive entries. A well-implemented fixed-timestep loop should hold update intervals within 1ms of the target timestep under normal load. Spikes beyond that point to GC pauses or sleep imprecision that the compensation pattern can address.

When you’re ready to extend the pattern, add a second entity to the shared state and verify that your volatile or synchronized access patterns still hold. Most threading bugs in game loops appear when a second object introduces a write ordering assumption that the first object didn’t expose. That’s where the double-buffer pattern earns its keep.

Founder and Chief Editor at  |  + posts

Jodie Bird is the founder and principal author of the Java Limit website, a dedicated platform for sharing insights, tips, and solutions related to Java and software development. With years of experience in the field, Jodie leads a team of seasoned developers who document their collective knowledge through the Java Limit journal.