1
\$\begingroup\$

I've been trying to create a deterministic, fixed gameloop.
The game loop should run TICK_RATE # of physics updates per second, and then render as quickly as possible (didn't do interpolation yet).
The issue I'm currently having is that the # of physics updates per second isn't constant. I want it to be the same as TICK_RATE, but it constantly changes by 1.
E.g. the tick-rate varies from 60-61 if TICKRATE = 60.

I thought this issue might be due to rounding issues, but manually calculating the delta time with System.nanoTime() and storing everything as a double gives me the same problem. If the physics simulation constantly varies by 1 frame per second, it's not longer deterministic, which I think would be hard to replicate (for networking and replays for example).
Any ideas on how to fix this so the number of physics updates per second is constant?

import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.Gdx

private const val TICK_RATE = 60 //Number of updates per second
private const val TIME_STEP = 1f / TICK_RATE //Seconds per tick
private const val MAX_FRAME_SKIP = (TICK_RATE * .2).toInt() //If fps drops below 20% of tickrate, slow down game to
                                                            //avoid spiral of death

class FixedTimestep : ApplicationAdapter() {

    private var accumulator = 0f

    //variables for ticks per second tracking
    private var tps = 0
    private var previousNanoTime = System.nanoTime()

    override fun render() {
        var frameSkipCount = 0
        val delta = Gdx.graphics.rawDeltaTime
        accumulator += delta
        while (accumulator > TIME_STEP && frameSkipCount < MAX_FRAME_SKIP) {
            accumulator -= TIME_STEP
            //do physics step

            //display tps to console every second
            tps++
            val currentNanoTime = System.nanoTime()
            if (currentNanoTime - previousNanoTime >= 1000000000L) {
                println(tps)
                tps = 0
                previousNanoTime = currentNanoTime
            }
        }
        //render
    }
}
\$\endgroup\$
2
  • \$\begingroup\$ What if you tried accumulator >= TIME_STEP instead of accumulator > TIME_STEP? \$\endgroup\$
    – Vaillancourt
    Jul 25, 2020 at 11:18
  • \$\begingroup\$ Nope, still the same result unfortunately \$\endgroup\$
    – Edward
    Jul 26, 2020 at 1:13

2 Answers 2

2
\$\begingroup\$

Having a constant time-step does not mean that you force your client to update exactly 60 times per real-life second, it means that whenever you update your physics the timestep you send to your physics engine must have a set value (in this case 1/60th of a second).

In other words your code is mostly correct:

TIMESTEP = 1 / 60;

accumulator = 0;

update(delta) {
    render(delta);
    accumulator += delta;

    // Don't forget the "=" too in case of the very rare edge-case where they are equal!
    while(accumulator >= TIMESTEP) {
        // THIS is the important part! 
        // This is where you must send TIMESTEP, not delta, to your physics engine.
        physicsTick(TIMESTEP);
    }
}

The fact that physicsTick(TIMESTEP) might in reality be called 4, 10, 60, 61, 581, ..., times per second should be irrelevant to you and is the entire reason behind the "accumulator pattern" (that the step can remain constant but the number of steps each second dynamic). What if a user has a super slow computer that can only handle 10 ticks per second? What if a hacker unlocks the tick rate to 2000 ticks per second? That should not break anything on the server and being 1 frame off every now and then definitely shouldn't.

Remember that the server is always the authority. When displaying a replay to the user or updating the positions of network entities you use the server's physics interpretation and send that to the client. This is how games like Overwatch handle replays Source here (YouTube: "Overwatch Gameplay Architecture and Netcode"). Sorry I don't have a timestamp but I recommend listening to the entire talk! They talk about clientside prediction and networking using fixed physics timestep.

\$\endgroup\$
0
\$\begingroup\$

The problem may be caused by disagreement between the clocks, either due to rounding errors or different timer resolution. Try using same source of current time and avoid time-delta accumulator — work with a timestamp instead.

Here is a "little" script in Python (sorry, I don't know Kotlin) to demonstrate what I mean. Run this file as a normal Python script (python script.py). Hit Ctrl+C after some time to show final stats. The script maintains 2 clocks (real and logical) while emulating core loop; the script prints message every time one of the clocks thinks one second has passed, where "sync" means clocks do agree (at exact same moment).

As per my own advice, 1) I derive frame count goal from a timestamp instead of using an accumulator and 2) all time-related information comes from same source (get_time). Notice how logical clock maintains stable update rate even when clocks do disagree.

Play with variables to cause the "spiral of death" or rubber-banding.

import time
import random

MS = 0.001

UPDATE_RATE = 60
RENDER_OVERHEAD = 3 * MS
UPDATE_OVERHEAD = 7 * MS
MAX_UPDATE_TIME = 200 * MS
OVERLOAD_INITIAL = 500 * MS
OVERLOAD_DELTA = -50 * MS

def main():
    now = get_time()
    real_clock = FixedTimestep(now, 1)
    logical_clock = FixedTimestep(now, UPDATE_RATE)
    subsystems = Subsystems()

    ups = 0
    update_count = 0
    stats_real_seconds = 0
    stats_logical_seconds = 0

    try:
        while True:
            subsystems.render()
            frame_time = get_time()
            while True:
                real_clock_advanced, real_second_passed = real_clock.advance(frame_time)
                logical_clock_advanced, logical_second_passed = logical_clock.advance(frame_time)

                if logical_clock_advanced:
                    subsystems.update()
                    update_count += 1

                if real_second_passed:
                    stats_real_seconds += 1

                if logical_second_passed:
                    stats_logical_seconds += 1
                    ups, update_count = update_count, 0

                if real_second_passed or logical_second_passed:
                    report(real_second_passed, logical_second_passed, ups)

                if not real_clock_advanced and not logical_clock_advanced:
                    break

                if MAX_UPDATE_TIME < (get_time() - frame_time):
                    break

    except KeyboardInterrupt:
        pass

    print("Done.")
    print(f"Stats: {stats_real_seconds} real seconds, {stats_logical_seconds} logical seconds.")
    print("Sanity check:", "pass" if (stats_logical_seconds == stats_real_seconds) else "SPIRAL OF DEATH")

def get_time():
    return time.time() # float, seconds since epoch

def report(real_second_passed, logical_second_passed, ups):
    real_text = "real" if real_second_passed else "----"
    logc_text = "logc" if logical_second_passed else "----"
    sync_text = "sync" if (logical_second_passed and real_second_passed) else "    "
    ups_text = f"{ups} UPS" if logical_second_passed else ""
    message = f"{real_text} {logc_text} | {sync_text} | {ups_text}"
    print(message)

def random_spread(margin):
    return random.uniform(1 - margin, 1 + margin)

class FixedTimestep:
    def __init__(self, now, tick_rate):
        self._start_time = now
        self._tick_rate = tick_rate
        self._count = 0

    def advance(self, now):
        local_now = now - self._start_time
        goal = int(local_now * self._tick_rate)
        if self._count < goal:
            self._count += 1
            second_passed = (self._count % self._tick_rate) == 0
            return (True, second_passed)
        return (False, False)

class Subsystems:
    def __init__(self):
        self._overload = OVERLOAD_INITIAL

    def render(self):
        delay = RENDER_OVERHEAD * random_spread(0.5)
        time.sleep(delay)

    def update(self):
        self._overload = max(0, self._overload + OVERLOAD_DELTA)
        delay = (UPDATE_OVERHEAD + self._overload) * random_spread(0.8)
        time.sleep(delay)

if __name__ == "__main__":
    main()
\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .