RewindableAction

Warning

RewindableActions are experimental, meaning the API may change in breaking ways, and may be less stable than other features.

Once the class matures and finds its final form, the experimental mark will be removed. Feedback is welcome in the meanwhile!

Synchronizes events that happen over the network, by letting peers predict the event happening, and then adjusting the game based on the host's response.

For example, RewindableActions could be use to synchronize gun shots implemented as part of the rollback tick loop. This is implemented in the rollback-fps example.

Using RewindableActions

To use RewindableActions, add them as nodes to your scenes. Once that's done, grab a reference to them from your scripts as you would for any other node - e.g. by using its NodePath, or by @export-ing it as a variable:

@onready var rewindable_action := $RewindableAction as RewindableAction
@export var rewindable_action: RewindableAction

Predicting events

All peers ( both hosts and clients ) should run the same simulation in their _rollback_tick implementations. During the rollback tick, peers should determine whether they think an event happens by calling RewindableAction.set_active() - e.g. if they think the gun was fired they should call RewindableAction.set_active(true), otherwise call RewindableAction.set_active(false).

The RewindableAction will keep track of the changes caused by set_active(). Clients ( i.e. peers not owning the RewindableAction ) will wait for the host ( i.e. peer owning the RewindableAction ) to broadcast the ground truth, noting when did the event happen, and when did it not.

Note

Not calling set_active() on a specific tick means no prediction for that tick will be synchronized, potentially leading to desyncs.

Performing events

With the above, RewindableAction will synchronize when something happens, but what should happen is up to the game logic.

For each rollback tick, to figure out what should happen, the get_status() method will return one of the following values:

RewindableAction.INACTIVE
The event hasn't happened yet.
RewindableAction.ACTIVE
The event has already happened, and this is not the first time its logic will run.
RewindableAction.CONFIRMING
The event was just set to active in this tick.
RewindableAction.CANCELLING
The event was just set to inactive in this tick.

See the following graph for a better understanding of how a RewindableAction transitions from one state to another:

INACTIVECONFIRMINGACTIVECANCELLINGset_active(true)Host confirmsTick is ran againset_active(false)Host declinesTick is ran again

Keeping with the gunfire example, if the status is ACTIVE or CONFIRMING, make sure to perform the firing logic - e.g. do a hitscan, and decrease the health of the player hit. In other words, make sure to update the game state.

If the state is CONFIRMING, implement logic that may spawn other objects ( e.g. a bullet hole when hitting a wall ).

If the state is CANCELLING, undo any logic ran in CONFIRMING.

Usually no extra code is necessary for INACTIVE, since the game state update can simply be skipped, and other related code is ran in CONFIRMING/CANCELLING.

Reacting to status changes

Without mutations, nodes are not always re-ran for every tick during rollback. To make sure that rollback code is ran when the RewindableAction's status changes, use mutate() to register the appropriate nodes to be mutated if the action's status changes, e.g.:

@onready var rewindable_action := $RewindableAction as RewindableAction

func _ready():
  rewindable_action.mutate(self)

func _rollback_tick(delta, tick, is_fresh):
  rewindable_action.set_active(...)

Remembering things between tick loops

RewindableActions also provide the concept of context. This is any arbitrary value that the RewindableAction will remember for the given tick, even throughout tick loops.

The context value can be set and retrieved by the user.

For example, context can be used for weapons to remember the projectile they have spawned. If the action transitions to CANCELLING, the context can be used to remember which projectile was spawned in that tick, and in turn, which projectile needs to be destroyed.

Use has_context() to check if there's any context set, get_context() to retrieve it, set_context() to update the context value, and erase_context() to forget it.

Handling visuals and effects

Performing events ( e.g. a gunshot ) often includes not just updates to the game state ( like decreasing health ), but also visual- and audio effects to communicate what's happening to the player.

Since a rollback tick loop may run multiple ticks in a single frame, simply playing sounds and other effects from the rollback tick loop can end up spamming particles and playing the same sound effects many times on the same frame.

Instead, one approach would be to check whether the event has happened at the end of each tick loop, and if so, play the appropriate sounds and run the appropriate effects.

Use has_confirmed() to check if the action has been confirmed since the beginning of the last tick loop ( i.e. had the CONFIRMING status ), and has_cancelled() to check if the action has been cancelled.

For example:

@onready var fire_action := $"Fire Action" as RewindableAction

func _ready():
    NetworkTime.after_tick_loop.connect(_after_loop)
    # ...

func _after_loop():
    if fire_action.has_confirmed():
        sound.play()