Record and Replay – Ghost Races

Lots of games give you the option to race against a ghost version of previous runs. Common in racing games like Mario Kart, Forza, and Gran Turismo, we took a stab at adding this feature to Messenger Mouse. With the Messenger Mouse ghost race feature, it’s now possible to toggle between racing your best time, and racing one of the developer’s times through a level. The setting is available in the options menu.

The latest development release is available in the Messenger Mouse development Google Drive folder. The May 2025 release has the initial ghost race implementation.

This isn’t the first time I’ve implemented some sort of record/replay for character movement in Godot. One of my first games from about 5 years ago had a character record/replay mechanic. This version is much more refined and simple.

Our goals for the ghost race feature were that the ghosts be shareable, small, and accurate. To make them shareable we went with an approach where each ghost is a separate file instead of integrating the ghost data into the player’s save file. This way sharing a ghost is as easy as sharing a file.

To make the files small, we save the data in a custom binary format rather than using a more bloated format like JSON. This makes them more challenging to parse and change but ensures the file only contains the bare minimum bytes of information needed. Additionally, we take advantage of the compression features available when writing files with Godot to compress the ghost race data so it’s truly as small as possible.

Finally, to make them accurate, we record the data in the _physics_process callback and we use the frame’s delta to increment a timestamp. This ensures that positions are recorded and synced independent from the current framerate. The following snippet is a stripped down version of the recording loop:

func _physics_process(delta: float) -> void:
    if not recording:
        return

    timestamp += delta
    var player: Player = MainInstances.player
    if not player or not is_instance_valid(player):
        return

    var current_pos: = Vector2(player.global_position.x, player.global_position.y)
    var current_entry = GhostRecorderEntry.new()
    current_entry.update_keymapping()
    current_entry.timestamp = timestamp
    current_entry.position = Vector2(current_pos.x, current_pos.y)
    current_entry.animation = player.animation_player.assigned_animation
    current_entry.attack_animation = player.attack_animation_player.assigned_animation
    current_entry.set_facing_from_scale(player.flip_anchor.scale.x)

    if not len(ghost_data):
        print_verbose("Player start: ", current_entry)
        ghost_data.append(current_entry)
        return

    var last_entry = ghost_data[-1]

    if (
        last_entry.position == current_pos and
        last_entry.animation == player.animation_player.assigned_animation and
        last_entry.attack_animation == player.attack_animation_player.assigned_animation and
        last_entry.facing == current_entry.facing
    ):
        return  # Don't store duplicate entries

    print_verbose("Record player position: ", current_entry)
    ghost_data.append(current_entry)Code language: PHP (php)

The above is just a snippet, but it’s fairly straightforward. We get the position, the keys currently being pressed, the animations being played, and the facing direction and save that in an array with the current timestamp. The keys are recorded with the update_keymapping() call which checks which keys are being pressed using the Input helpers in Godot and saves the information as a bitmap. We also make sure not to record any duplicate entries. If nothing changes there’s no need to make the ghost data larger.

To create the ghost player and replay the data, we simply create a simplified version of the player. It doesn’t need all the collision shapes, and other nodes, all it needs is the player’s sprite and animation players to play the movement and attack animations.

The ghost player script is so simple we can show the whole thing:

extends Node2D
class_name GhostPlayer

@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var attack_animation_player: AnimationPlayer = $AttackAnimationPlayer
@onready var flip_anchor: Node2D = $FlipAnchor

var ghost_data: Array[GhostRecorderEntry] = [] : set = set_ghost_data
var timestamp: float = 0.0
var target_pos: Vector2

func set_ghost_data(value: Array[GhostRecorderEntry]) -> void:
    ghost_data = value.duplicate()

func _physics_process(delta: float) -> void:
    if not len(ghost_data):
        return  # Not initialized yet

    var next_pos: GhostRecorderEntry = null
    timestamp += delta

    while len(ghost_data):
        next_pos = ghost_data.pop_front() as GhostRecorderEntry
        if not len(ghost_data) or timestamp <= ghost_data[0].timestamp:
            break

    if not next_pos:
        return

    target_pos = next_pos.position
    global_position = lerp(global_position, target_pos, 0.90)
    if next_pos.animation and next_pos.animation != animation_player.assigned_animation:
        animation_player.play(next_pos.animation)
    if next_pos.attack_animation and next_pos.attack_animation != attack_animation_player.assigned_animation:
        attack_animation_player.play(next_pos.attack_animation)

    flip_anchor.scale.x = next_pos.get_facing_as_scale()Code language: PHP (php)

We can’t be sure that the game will be running at the same rate now that it was recording at. It could be running on different hardware. So as we get physics frame callbacks in the _physics_process we check the ghost_data array and pop off each entry that has an old timestamp. So if we miss one because the game is running too slow or fast, then we’ll just skip it.

We lerp the position of the ghost to handle situations where we have to skip an entry so that no matter what, the ghost moves smoothly through the level.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *