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:
- The joiner’s scene tree must have the same MultiplayerSpawner at the same node path. Path mismatch = no replication.
- Each scene being replicated must be listed in
spawnable_scenes(or registered viaadd_spawnable_scene) on both sides. - 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.