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
 }
}
asked Jul 25, 2020 at 4:57
\$\endgroup\$
2
  • \$\begingroup\$ What if you tried accumulator >= TIME_STEP instead of accumulator > TIME_STEP? \$\endgroup\$ Commented Jul 25, 2020 at 11:18
  • \$\begingroup\$ Nope, still the same result unfortunately \$\endgroup\$ Commented 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.

answered Jul 27, 2020 at 14:04
\$\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()
answered Jul 27, 2020 at 1:57
\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.