1
\$\begingroup\$

I am working on an arcade-style 2D shmup and recently started implementing a replay system that allows players to view their runs of the game.

I use the technique of recording input each FixedUpdate() and rerunning the game as a simulation, reading the saved input each frame and processing it in the core game loop as if it was real-time input from the player.

This requires an extremely deterministic game, and in order to get perfectly synced movement and scoring in the replay, I have had to shift to a paradigm of using exclusively FixedUpdate() and Time.fixedDeltaTime.

I don’t mind this, as I now have 100% reliable replays with no desyncing.

My question concerns whether some of the techniques I used to port my game to the deterministic FixedUpdate paradigm are problematic, or if there are better ways to achieve the same effect.

For example, previously I relied heavily on coroutines yielding using WaitForSeconds(). This method is underpinned by normal Update() loop and so introduced indeterminism into the game leading to desyncs.

To fix it, I still use coroutines, but any time I need to wait, I do it by waiting for n number of calls of FixedUpdate(). For example,

for (int i = 0; i < 60; i ++)
{
    yield return new WaitForFixedUpdate();
}

This seems to be working just fine to keep the game deterministic, but I wonder if there’s a more straightforward way, or if there are potential issues this type of technique might cause?

In general, are there any other techniques or pitfalls I need to be aware of to keep my game deterministic enough for perfectly synced replays?

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Note that Time.deltaTime automatically returns Time.fixedDeltaTime when accessed during FixedUpdate or collision message handlers, so explicitly changing old deltaTime references might not be required for all cases. \$\endgroup\$
    – DMGregory
    Commented Sep 17, 2021 at 3:03

1 Answer 1

0
\$\begingroup\$

After more tinkering, I think I can safely say that using

for (int i = 0; i < 60; i ++)
{
    yield return new WaitForFixedUpdate();
}

works fine in coroutines to preserve determinism.

That said, there were a few other things I needed to change, and others attempting to move to a FixedUpdate paradigm should be aware of them.

In many of my coroutines, I was sometimes using yield return null. This will yield until the next call of Update(), which is problematic. I had to convert these lines to yield return new WaitForFixedUpdate().

Finally, I am making heavy use of the DOTween library for tweening. By default, all of the tween update using Update(), but I discovered that you can easily set all of them to update during FixedUpdate() using

    // set Global tween settings
    // NOTE: this is easiest way to make sure all tweens are only
    // updating in fixed update,
    // rather than manually setting tween.SetUpdate(UpdateType.Fixed)
    DOTween.Init();
    DOTween.defaultUpdateType = UpdateType.Fixed;
\$\endgroup\$

You must log in to answer this question.

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