Quick answer: Pre-register every scene in spawnable_scenes, ensure the spawner’s parent path exists on the joining peer, and confirm the server holds multiplayer authority for the spawn parent.

A peer joins a match in progress. The server has three players already in the lobby. The new joiner sees an empty lobby — their MultiplayerSpawner tree contains no children. Existing peers see the new joiner fine; only the joiner is blind.

How MultiplayerSpawner Handles Late Joins

When a peer connects, the server’s MultiplayerSpawner reads its current child list under spawn_path and replicates each spawn to the new peer. Three preconditions must hold:

  1. The joiner’s scene tree must have the same MultiplayerSpawner at the same node path. Path mismatch = no replication.
  2. Each scene being replicated must be listed in spawnable_scenes (or registered via add_spawnable_scene) on both sides.
  3. The server must hold multiplayer authority over the spawn parent — otherwise it doesn’t broadcast spawns.

Step 1: Pre-Register Scenes

# level.tscn structure
Level
  PlayersContainer (Node) <-- spawn_path target
  MultiplayerSpawner
    spawn_path = ^"../PlayersContainer"
    spawnable_scenes = [
      preload("res://player.tscn"),
    ]

Or programmatically before any spawns:

@onready var spawner = $MultiplayerSpawner

func _ready():
    spawner.add_spawnable_scene("res://player.tscn")
    spawner.add_spawnable_scene("res://enemy.tscn")

Both peers must call this before the join completes. The easiest pattern is to call them in _ready of the level scene that contains the spawner.

Step 2: Use spawn_function for Custom Payloads

If your spawned scenes need per-instance configuration (player ID, starting position, character class), use spawn_function:

spawner.spawn_function = _spawn_player

func _spawn_player(data: Dictionary) -> Node:
    var player = preload("res://player.tscn").instantiate()
    player.peer_id = data.id
    player.position = data.spawn_pos
    return player

# Server spawns:
spawner.spawn({"id": peer_id, "spawn_pos": Vector2(100, 100)})

The dictionary is serialized with each spawn event and replicated automatically to all peers including late joiners.

Step 3: Authority on the Spawn Parent

func _ready():
    if multiplayer.is_server():
        $PlayersContainer.set_multiplayer_authority(multiplayer.get_unique_id())

Without this, the server doesn’t broadcast new spawns under that parent. Set authority once during scene initialization on the server only.

Step 4: Replicating Existing State

MultiplayerSpawner replicates spawn events, not arbitrary node state. If a player has moved since they spawned, the late joiner sees them at the spawn position, not their current position. Combine with a MultiplayerSynchronizer on each player to replicate position/health/etc.:

Player
  MultiplayerSynchronizer
    replication_config: replicates `position`, `velocity`, `health`

The Synchronizer sends current state to the joining peer the moment the spawn replicates. Late joiners now see the world correctly.

Verifying

Host with two clients connected and a third joining later. Have the server print $PlayersContainer.get_children() on each peer’s ready event. All peers should report the same number of player nodes. If the joiner’s list is empty or short, one of the three preconditions above is failing — usually spawnable_scenes missing the scene.

“MultiplayerSpawner replays the child list at join time. If the list is empty on the joiner, you registered the wrong path, the wrong scenes, or the wrong authority.”

Pair every MultiplayerSpawner with a MultiplayerSynchronizer on each spawnable scene — otherwise late joiners see stale positions.