Godot 4 Multiplayer Tutorial:
Add Multiplayer to a Platformer Game

30 min read
Beginner
Godot 4.x
Updated 2026

What You'll Build

The GD-Sync Platformer Template is a complete single-player 2D platformer. Your character can run, jump, shoot bullets, collect coins, and fight enemies. In this tutorial you will transform it into a fully functional multiplayer game.

By the end, multiple players can join the same lobby, see each other move and animate in real time, shoot bullets that appear on every screen, and share the same enemies and coins. Everything stays perfectly synchronized with almost zero extra code.

What you need before you start

1

GD-Sync Platformer Template
Download the free template project from GitHub. It will be used as the foundation for this tutorial.

2

Godot 4.5+
Any stable Godot 4.5+ release works. Download from the official Godot website.

3

GD-Sync account and API key
Sign up for free here. You can create an API key on your account dashboard.

Part 1: Install and Configure the Plugin

With the template project open, go to the AssetLib tab at the top of the editor. Search for GD-Sync, select the result, then click Download and Install. Accept the default files and confirm.

Next, open Project Settings, go to the Plugins tab, and enable GD-Sync. Reload the project when prompted. You will see a message that GD-Sync is enabled in the console after reloading.

Open the GD-Sync configuration window from the top menu under Project > Tools > GD-Sync. Go to your GD-Sync dashboard, create a new API key for this project, copy it, and paste it into the field. Save the settings.

Why an API key? It authenticates your project and authorizes your game to use our global relay network. It also enables the collection of player statistics for your dashboard. While not required for local LAN play, it is essential for all online features.

Don't have an API key yet?

Create a free account and instantly get access to all features.

Get Free API Key

Part 2: Establishing a Connection

All GD-Sync features are accessible through the global GDSync singleton. To begin, open your main.gd script, this is where we will implement the core logic for connections and lobby management.

First, we need to listen for the connected and connection_failed signals. Once the listeners are set up, call GDSync.start_multiplayer() to initiate a connection to the GD-Sync network. If the plugin successfully reaches a server, the connected signal will fire. If not, connection_failed will be emitted.

GDScript
func _ready() -> void:
	GDSync.connected.connect(connected)
	GDSync.connection_failed.connect(connection_failed)
	GDSync.start_multiplayer()

func connected() -> void:
	print("Connected!")

func connection_failed(error : int) -> void:
	match(error):
		ENUMS.CONNECTION_FAILED.INVALID_PUBLIC_KEY:
			push_error("The public or private key you entered were invalid.")
		ENUMS.CONNECTION_FAILED.TIMEOUT:
			push_error("Unable to connect, please check your internet connection.")

If you run the project, you should see it print that the client is now successfully connected in the console. Now that the connection is established, we can start working on the lobby code.

Part 3: Creating & Joining Lobbies

Now that your project is connected to the GD-Sync network, players need a place to meet. In GD-Sync, lobbies act as private or public rooms where players are grouped together to share data. Without a lobby, players are connected to the network but remain isolated.

Just like with connecting, we need to listen to specific signals when creating a lobby. We must listen to the lobby_created and lobby_creation_failed signals. Once those signals are connected, we can create a lobby with GDSync.create_lobby(). For this tutorial, we will simply create a lobby once we are connected.

GDScript
func _ready() -> void:
	# ... existing connection signals ...

	# Listen for lobby creation events
	GDSync.lobby_created.connect(lobby_created)
	GDSync.lobby_creation_failed.connect(lobby_creation_failed)

	GDSync.start_multiplayer()

func connected() -> void:
	print("Connected!")

	# Automatically create a new lobby once connected
	GDSync.lobby_create("TestLobby")

# ... existing connection_failed function ...

func lobby_created(lobby_name : String) -> void:
	# This function is called when the lobby is successfully created
	print("Created lobby ", lobby_name)

func lobby_creation_failed(lobby_name : String, error : int) -> void:
	# This function is called when the lobby failed to create. You can optionally handle the error code.
	print("Failed to create lobby ", lobby_name)

Now that we can create a lobby, we need to join it. We must first connect the lobby_joined and lobby_joined_failed signals. Then we can call GDSync.join_lobby() to join the lobby we are creating.

GDScript
func _ready() -> void:
	# ... existing connection signals ...

	# Listen for lobby join events
	GDSync.lobby_joined.connect(lobby_joined)
	GDSync.lobby_join_failed.connect(lobby_join_failed)

	GDSync.start_multiplayer()

# ... existing connected and connection_failed functions ...

func lobby_created(lobby_name : String) -> void:
	print("Created lobby ", lobby_name)

	# Automatically join the lobby after successfully creating it
	GDSync.lobby_join(lobby_name)

func lobby_creation_failed(lobby_name : String, error : int) -> void:
	print("Failed to create lobby ", lobby_name)

	# If the lobby already exists we can just join it instead
	if error == ENUMS.LOBBY_CREATION_ERROR.LOBBY_ALREADY_EXISTS:
		GDSync.lobby_join(lobby_name)

func lobby_joined(lobby_name : String) -> void:
	# This function is called when you successfully join a lobby
	print("Joined lobby ", lobby_name)

func lobby_join_failed(lobby_name : String, error : int) -> void:
	# This function is called when you fail to join a lobby
	print("Lobby join failed ", lobby_name)

Godot allows us to easily test multiplayer, as you can tell the engine to start multiple instances of your game instead of just one. To do this, go to Debug > Customize Run Instances. There you can click Enable Multiple Instances and select how many instances you want to launch.

Once enabled, every time you press the Play button, Godot will open the specified number of windows. This allows you to create a lobby in one window and join it from the other to verify your multiplayer logic in real time.

If you now run the game, two instances of your game launch. You should see that both players successfully connect to the network. In this setup, one player creates the lobby and joins it, and the other player joins it immediately after.

Part 4: Spawning & Synchronizing Players

Now that we have successfully joined a lobby on two clients, we can begin synchronizing the actual gameplay. First, remove the original player node from the scene tree because we will spawn all players from code instead. Delete the player node located at Players/Player.

Create a new script on the Players node and name it player_spawner.gd. In this script, we will listen for the client_joined and client_left signals. These signals are emitted on every client whenever someone joins or leaves the lobby.

When a client joins, instantiate a new player scene and add it as a child of the Players node. It is important that the node path of every spawned player is identical across all clients because GD-Sync uses the node path to locate and synchronize nodes remotely. Since Godot assigns random names to newly instantiated nodes, manually set each player's name to its client ID. This guarantees that the node path stays consistent on every client. Also assign ownership over the player to the joining client. This will later be used to give each client authority over their own player.

When a client leaves, use the same client ID to find and remove their player node from the scene.

GDScript
# Do not forget to assign the player scene in the inspector
@export var player_scene : PackedScene

func _ready() -> void:
	# Listen for when clients join or leave the current lobby
	GDSync.client_joined.connect(client_joined)
	GDSync.client_left.connect(client_left)

func client_joined(client_id : int) -> void:
	print("Client joined ", client_id)

	# Check if the client that just joined is our own local client
	if client_id == GDSync.get_client_id():
		print("Own id ", client_id)

	# Instantiate the player scene and set its name to the client ID
	var player : Node2D = player_scene.instantiate()
	add_child(player)
	player.name = str(client_id)

	# Grant this specific client ownership over their player node
	GDSync.set_gdsync_owner(player, client_id)

func client_left(client_id : int) -> void:
	print("Client left ", client_id)

	# Find the player node associated with the disconnected client and remove it
	var player : Node2D = get_node_or_null(str(client_id))
	if player != null:
		player.queue_free()

Each client now has their own player spawned with ownership correctly assigned. However, every player scene currently responds to keyboard input. We need to make sure only our own player reacts to input. We will use the ownership we assigned earlier to achieve this.

In the player.gd script, call GDSync.connect_gdsync_owner_changed() to listen for ownership changes. Then check whether this player belongs to us. If it does, enable the camera so it follows our player instead of another client's player.

In the _physics_process function, add a simple check at the top that returns early if this is not our own player. This ensures all movement and input logic only runs for our local player. You can add the same kind of check in other functions as well. For example, in the kill function, make sure we can only kill our own player. This approach lets each client run its own player's logic locally while we synchronize the results to the other players later.

GDScript
extends Character

# Add a reference to the player's camera to toggle it based on ownership
@export var camera : Camera2D
@export var bullet_scene : PackedScene

# ... existing variables ...

func _ready() -> void:
	spawn()

	# Listen for when the ownership of this node changes
	GDSync.connect_gdsync_owner_changed(self, owner_changed)

# This function is called whenever the owner of this node changes
func owner_changed(owner_id : int) -> void:
	var is_owner : bool = GDSync.is_gdsync_owner(self)

	# Only enable the camera if this instance belongs to our local client
	camera.enabled = is_owner

func _physics_process(delta: float) -> void:
	# Prevent other clients from processing input and moving this player
	if !GDSync.is_gdsync_owner(self):
		return

	# ... existing physics and input logic ...

func spawn() -> void:
	# ... existing spawn logic ...
	pass

func kill() -> void:
	# Only the owner should handle their own death logic and respawn timers
	if !GDSync.is_gdsync_owner(self):
		return

	# ... existing kill logic ...

Now that we have full control over our own player and other players no longer respond to our keyboard inputs, we need to make sure everyone can see our movement. To do this, we will synchronize the position of our player using GD-Sync's PropertySynchronizer node.

Open the player.tscn scene and add a PropertySynchronizer node as a child of the player. Set the Broadcast Mode to When Owner. This ensures that only our own player broadcasts its position to the other clients. Next, set the Target Node to the player itself. Add global_position to the list of properties to synchronize. Finally, enable interpolation to smooth out the movement between updates. It is recommended to set the interpolation speed to the same value as the Refresh Rate.

This simple setup will make all players see each other move smoothly in real time.

Players can now see each other move, but the animations are not yet synchronized. We need to synchronize both the AnimatedSprite and the AnimationPlayer that controls the death and respawn animations.

To synchronize the AnimatedSprite, right-click on it in the scene tree and change its type to SynchronizedAnimatedSprite2D. Once replaced, open the character_sprite.gd script and change the extends line from AnimatedSprite2D to SynchronizedAnimatedSprite2D. Then replace all calls to play() with play_synced().

This ensures the animation state stays perfectly in sync across all clients.

GDScript
extends SynchronizedAnimatedSprite2D

@export var max_speed : float = 200.0

func _on_character_moved(velocity: Vector2, moving: bool, on_floor : bool) -> void:
	if moving:
		play_synced("move")
		flip_h = velocity.x >= 0
		speed_scale = velocity.x/max_speed
	else:
		play_synced("idle")
		speed_scale = 1

func died() -> void:
	play_synced("dead")

The AnimationPlayer also needs to be synchronized. Right-click on the AnimationPlayer node in the scene tree and change its type to SynchronizedAnimationPlayer.

Changing the node type resets the signal connections, so we must restore them. The died signal should call play_synced with "died" as the animation name. The spawned signal should call play_synced with "spawn" as the animation name. You can edit the signals by right-clicking on them and selecting Edit. Make sure the connections exactly match the setup shown in the image below.

This keeps the death and respawn animations perfectly synchronized for all players.

Part 5: Spawning & Synchronizing Bullets

Now that players and their animations are fully synchronized, we can move on to interacting with other objects in the scene. Currently, when the player shoots, bullets are only spawned locally on our own client. We will fix this by using GD-Sync's built-in NodeInstantiator to spawn bullets on all clients at the same time, combined with a PropertySynchronizer to keep their positions synchronized.

First, open the player.tscn scene and add a NodeInstantiator node as a child of the player. In the inspector, assign the bullet.tscn scene as the scene to be instantiated. Next, update the player script to use this NodeInstantiator instead of instantiating the bullet locally. When a player shoots a bullet, we give them ownership of it. Make sure to assign the NodeInstantiator reference in the inspector so the script can access it.

This setup ensures every bullet appears instantly and consistently for all players in the lobby.

GDScript
extends Character

@export var camera : Camera2D
# Replace the PackedScene export with a NodeInstantiator reference
@export var bullet_instantiator : NodeInstantiator

# ... existing variables and functions (_ready, owner_changed) ...

func _physics_process(delta: float) -> void:
	# ... existing physics and movement logic ...
	
	#Handle shooting
	if Input.is_action_just_pressed("shoot") and controls_enabled:
		# Instantiate the bullet using the NodeInstantiator instead of the normal scene tree
		var bullet : Projectile = bullet_instantiator.instantiate_node()
		
		# Give the local client ownership over the spawned bullet so they can control it
		GDSync.set_gdsync_owner(bullet, GDSync.get_client_id())
		
		bullet.global_position = global_position
		bullet.direction.x = last_direction

# ... existing spawn and kill functions ...

Now that bullets are spawning on all clients, we need to make sure their movement is synchronized as well. In this section we will let each player simulate their own bullets locally and broadcast the position to everyone else.

First, open the bullet.tscn scene and add a PropertySynchronizer node as a child of the bullet. Set the Broadcast Mode to When Owner. Because bullets are less critical than players, we can use a lower refresh rate. Set it to broadcast the position 15 times per second. Also enable interpolation so the movement looks smooth on other clients.

Next, update the bullet.gd script so that the bullet logic only runs on the client that owns it. Add a check at the top of _physics_process to return early if this bullet is not owned by the local client.

When it is time to destroy a bullet, we need queue_free to be called on every client, not just our own. GD-Sync makes this easy with remote function calls. We can use GDSync.call_func() to call a function on all clients except ourself, GDSync.call_func_all() to call a function on all clients including ourself, or GDSync.call_func_on() to call a function on a specific client. In this case, we want to destroy the bullet on all clients, so we are going for call_func_all.

Make sure the function you want to call remotely is exposed. By default GD-Sync blocks all remote calls for safety. In the bullet script, use GDSync.expose_func() to allow queue_free to be called from other clients.

This keeps bullet spawning, movement, and destruction perfectly synchronized across the entire lobby.

GDScript
extends CharacterBody2D
class_name Projectile

# ... existing variables ...

func _ready() -> void:
	# Expose the queue_free function so it can be called across the network
	GDSync.expose_func(queue_free)

func _physics_process(delta: float) -> void:
	# Only the owner of the bullet should process its movement and collisions
	if !GDSync.is_gdsync_owner(self):
		return
	
	# ... existing movement logic ...
	
	#Despawn bullet automatically
	if time > lifetime:
		destroy_bullet()
	
	#Despawn on collision
	if collision != null:
		destroy_bullet()
		
		# ... existing character kill logic ...

func destroy_bullet() -> void:
	# Free the bullet locally and on all other clients
	GDSync.call_func_all(queue_free)

We must also handle the case when a bullet hits an enemy. In this tutorial, we will run important logic such as enemy AI and bullet collisions on the host client only.

When a bullet hits an enemy, it currently calls the kill function directly. We need to make sure this kill function is executed on the host so all enemy-related logic stays consistent and centralized.

First, open the character.gd script and expose the kill function so GD-Sync can call it remotely. Then in the bullet script, instead of calling kill directly, use GDSync.call_func_on() to call it on the host client. You can get the host client ID with GDSync.get_host(). This keeps all critical enemy logic running only on the host while still allowing every client to trigger it.

GDScript
extends CharacterBody2D
class_name Character

# ... existing signals and variables ...

func _init() -> void:
	# Expose the kill function so it can be called over the network
	GDSync.expose_func(kill)

func kill() -> void:
	died.emit()
GDScript
extends CharacterBody2D
class_name Projectile

# ... existing variables and _ready function ...

func _physics_process(delta: float) -> void:
	# ... existing movement and despawn logic ...
	
	#Despawn on collision
	if collision != null:
		destroy_bullet()
		
		var collider : Node = collision.get_collider()
		if collider is Character:
			# Tell the host to kill the character that was hit to ensure authority
			GDSync.call_func_on(GDSync.get_host(), collider.kill)

# ... existing destroy_bullet function ...

If you launch the game now, you should see that bullets are fully synchronized across all clients. They spawn, move, and disappear for everyone in the lobby.

Part 6: Synchronizing Enemies

Enemies are not yet synchronized and are still running their logic locally on every client. We will change this so that all enemy logic executes only on the host and is then broadcast to every other client in the lobby.

First, open the enemy.gd script. We will make two important changes. Add a check at the top of the _physics_process function so the code only runs on the host client and is skipped for everyone else. Second, just like we did with bullets, replace the manual instantiation with a NodeInstantiator. This ensures that coins drop on all clients when an enemy is hit by a bullet. Make sure to add the NodeInstantiator to the enemy, set the coin scene as the scene to instantiate, and assign it to the enemy inspector.

GDScript
extends Character

# ... existing signals and variables ...

@export_group("Drops")
# Replace PackedScene with NodeInstantiator for multiplayer spawning
@export var drop_instantiator : NodeInstantiator
@export var drop_amount_range : Vector2i

# ... existing variables, _ready, and spawn functions ...

func _physics_process(delta: float) -> void:
	# Only the host should process AI logic to keep enemies in sync for all players
	if !GDSync.is_host():
		return
	
	# ... existing physics, gravity, and state machine logic ...

# ... existing update_rays, body_entered, body_exited, and kill functions ...

func drop_items() -> void:
	#Spawn in drops and animate them
	for i in randi_range(drop_amount_range.x, drop_amount_range.y):
		# Use the NodeInstantiator to create dropped items across the network
		var drop : Node2D = drop_instantiator.instantiate_node()
		drop.global_position = global_position
		
		# ... existing drop position calculation and tween animation ...

To synchronize the enemy positions, we will do almost the same thing we did for the players. Add a PropertySynchronizer node and set the values in the inspector as shown to synchronize the enemy's position.

The only noticeable difference is that we set the Broadcast Mode to When Host. This ensures the host client is the only one broadcasting enemy positions to the other clients, giving the host full authority over all enemies.

We must also replace the AnimationPlayer with a SynchronizedAnimationPlayer, just like we did for the player. Right-click on the AnimationPlayer node in the scene tree and change its type to SynchronizedAnimationPlayer.

Changing the node type resets the signal connections, so we need to restore them. The enemy_detected signal should call play_synced with "enemy_detected" as the animation name. The enemy_lost signal should call play_synced with "enemy_lost" as the animation name. The died signal should call play_synced with "kill" as the animation name.

You can edit the signals by right-clicking on them and selecting Edit. Make sure the connections exactly match the setup shown in the image below.

Part 7: Picking Up Coins

To finish the tutorial, we only need to synchronize coin collection.

First, open the coin.gd script and add a check to ensure that only our own player can pick up the coin. Next, we need to make sure that when any player collects a coin, it gets hidden for everyone else in the lobby. To do this, create a new function called hide_coin. Expose this function so it can be called remotely, then use GDSync.call_func_all() to call it on all clients when a coin is picked up.

This final step ensures coins are collected cleanly and consistently across all players.

GDScript
extends Node2D

signal picked_up

func _ready() -> void:
	GDSync.expose_func(hide_coin)

func _on_area_2d_body_entered(body: Node2D) -> void:
	if !visible: return
	
	#Check if this body is our player
	#We only want our own player to pick up coins
	if !GDSync.is_gdsync_owner(body):
		return
	
	#Hide the coin after it has been picked up
	GDSync.call_func_all(hide_coin)
	
	#Register the collected coin
	get_tree().get_first_node_in_group("coin_menu").coin_collected()

func hide_coin() -> void:
	picked_up.emit()
	visible = false

Frequently Asked Questions

Running enemy AI (like movement, gravity, and collision) only on the host prevents desynchronization. If every client calculated enemy movement locally, slight differences in frame rates or physics steps would eventually cause enemies to be in completely different places on different screens. By making the host the "single source of truth," the host calculates the logic and uses the PropertySynchronizer to broadcast the correct position to everyone else.

Using Godot's normal instantiate() function only creates the object on your local screen. If you shoot a bullet that way, no one else will see it. The NodeInstantiator is a built-in GD-Sync tool that intercepts the spawn request and automatically replicates the node across all connected clients simultaneously. This guarantees that the game world contains the exact same objects for everyone in the lobby.

You can test it over the internet immediately! Because GD-Sync routes traffic through a managed global relay network, you do not need to configure port forwarding, deal with strict firewalls, or rent a dedicated server. As long as you have entered your API key in the project settings, you can export the game, send it to a friend, and they can join your lobby from anywhere in the world.

Interpolation smooths out the movement of other players and objects. Instead of sending position updates every single frame (which would waste network bandwidth), GD-Sync sends updates a few times per second. Interpolation automatically calculates the spaces between those updates so characters glide smoothly across your screen instead of stuttering or teleporting.

GD-Sync features automatic host migration. If the current host leaves or loses their internet connection, the GD-Sync network instantly promotes another player in the lobby to be the new host. Because our enemy script simply checks if GDSync.is_host(), the newly promoted player will seamlessly take over calculating the enemy AI and dropping coins, keeping the game running without interruption.