Two laptops showing the same small room with two clay figures
| |

How to Build a Multiplayer Game in Godot 4.x

Build one small shared room in Godot 4 where two players connect, spawn, and watch each other move, with the server in charge of movement and a clear split between server and client code.

This is a build, not a theory lesson. By the end you will have a 2D room, a Host button, a Join button, and two game windows on your own computer where each player sees the other walk around.

You will use Godot’s high-level multiplayer API: an ENet peer for the connection, a MultiplayerSpawner to create player nodes on every machine, a MultiplayerSynchronizer to copy positions, and one RPC to send input. The class names here match the current stable Godot documentation. Godot 4.x patches sometimes rename an Inspector label or move a panel, so if something looks different, match the docs for your version.

Who does what

Decide this before you write any code. It shapes every line.

The server (the host) does:

  • Opens the connection and accepts players.
  • Creates a player node for each peer that joins and removes it when they leave.
  • Moves every player body, using input it received.
  • Owns the true position of every player.

Each client does:

  • Connects to the server.
  • Reads its own keyboard and sends that input to the server.
  • Displays the positions the server sends back.

In this build, the host is also a player. Its own input goes straight into its own player node, with no network hop. Godot’s documentation recommends this server-authoritative shape: clients send intent, and the server decides the result. It makes cheating harder and keeps everyone in sync.

Step 1: Build the player scene

Create a new scene with a CharacterBody2D as the root and name it Player. Add a Sprite2D (the default icon is fine) and a CollisionShape2D with a small shape. Save it as player.tscn.

Now add a MultiplayerSynchronizer as a child of Player. Its root path defaults to its parent, which is what you want.

Select the synchronizer and open its replication settings; check the docs for where that editor appears in your patch. Add the Player node’s position property to the list. This tells Godot to copy that one property from the node’s authority to all other peers. Every node’s authority is the server by default, so the server’s position for each player will flow out to every client.

Leave the replication mode at its default for now. The docs describe an Always mode and an On Change mode; you can experiment later.

Step 2: Build the main scene

Create a new scene with a Node2D root named Main. Give it these children:

  • A Node2D named Players. Spawned player nodes will live here.
  • A MultiplayerSpawner. In the Inspector, set its spawn path to the Players node and add player.tscn to its list of spawnable scenes.
  • A CanvasLayer named UI holding two Buttons, HostButton and JoinButton.

The spawner is the piece that makes “two players see each other” possible. When the server adds player.tscn as a direct child of Players, the spawner creates the same node on every connected client. When a new client joins later, it receives the players that already exist.

Set Main as the project’s main scene.

Step 3: Write the connection code

Attach a script to Main and connect each button’s pressed signal to the matching function.

extends Node2D

const PORT = 7000
const PLAYER_SCENE = preload("res://player.tscn")

@onready var players_root = $Players

func _on_host_button_pressed():
    var peer = ENetMultiplayerPeer.new()
    var err = peer.create_server(PORT, 4)
    if err != OK:
        print("Could not host: ", err)
        return
    multiplayer.multiplayer_peer = peer
    multiplayer.peer_connected.connect(_add_player)
    multiplayer.peer_disconnected.connect(_remove_player)
    _add_player(1)  # The host is peer 1 and also plays.
    $UI.hide()

func _on_join_button_pressed():
    var peer = ENetMultiplayerPeer.new()
    var err = peer.create_client("127.0.0.1", PORT)
    if err != OK:
        print("Could not join: ", err)
        return
    multiplayer.multiplayer_peer = peer
    $UI.hide()

# Runs on the server only.
func _add_player(id):
    var player = PLAYER_SCENE.instantiate()
    player.name = str(id)
    player.position = Vector2(150 + 80 * players_root.get_child_count(), 200)
    players_root.add_child(player, true)

# Runs on the server only.
func _remove_player(id):
    var player = players_root.get_node_or_null(str(id))
    if player:
        player.queue_free()

A few things to notice.

Only the host connects to peer_connected and peer_disconnected, so only the server ever creates or removes players. Clients never call _add_player. They get their copies from the spawner.

Each player node is named after its peer ID. Godot gives the server ID 1 and gives each client a random positive ID. Naming nodes this way means every machine can tell which node belongs to which peer, and it keeps node paths identical everywhere, which RPCs require. The true passed to add_child asks Godot for a readable name, as the docs recommend for nodes that use RPCs.

127.0.0.1 means “this computer.” That is all you need for local testing. Connecting across a network is a later step.

Step 4: Write the player script

Attach this script to the Player root in player.tscn.

extends CharacterBody2D

const SPEED = 200.0

var input_dir := Vector2.ZERO  # Server-side copy of this player's input.

func _physics_process(_delta):
    var is_mine = str(multiplayer.get_unique_id()) == str(name)

    # CLIENT AND HOST: read input only for your own player.
    if is_mine:
        var dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
        if multiplayer.is_server():
            input_dir = dir
        else:
            send_input.rpc_id(1, dir)

    # SERVER ONLY: move every body.
    if multiplayer.is_server():
        velocity = input_dir * SPEED
        move_and_slide()

# Clients call this on the server.
@rpc("any_peer", "call_remote", "unreliable_ordered")
func send_input(dir: Vector2):
    if not multiplayer.is_server():
        return
    # Only the owner of this player may steer it.
    if multiplayer.get_remote_sender_id() != str(name).to_int():
        return
    input_dir = dir.limit_length(1.0)

Walk through the split one more time.

On a client, is_mine is true for exactly one player node. That client reads the arrow keys and sends the result to peer 1 with rpc_id. It never calls move_and_slide itself. The position it sees comes from the synchronizer.

On the server, every player node runs the movement block. The host’s own node uses local input. Every other node uses the latest input_dir that arrived by RPC.

The @rpc annotation uses "any_peer" because clients need permission to call it. By default only the authority, the server here, may call an RPC. Opening it up means you must check who called it, which is what the get_remote_sender_id line does. It rejects input from anyone except the peer who owns that node. limit_length stops a modified client from sending a giant vector to move faster.

The transfer mode is "unreliable_ordered". Input is sent every physics frame, so a lost packet does not matter; the next one replaces it. The docs suggest this mode for movement data that would be stale if resent.

Step 5: Test with two game instances

You do not need two computers. Godot can launch several copies of your game from the editor.

  1. Open the Debug menu and choose Customize Run Instances.
  2. Turn on Enable Multiple Instances and set the count to 2.
  3. Close the dialog and run the project.

Two windows open, often stacked on top of each other, so drag one aside. Click Host in the first window. Click Join in the second.

You should see two player icons in both windows. Press the arrow keys in the host window and its icon moves in both. Click the client window, press the arrows, and the other icon moves in both.

If something goes wrong, check these first:

  • The client sees nothing. Confirm player.tscn is in the spawner’s spawnable list and the spawn path points to Players.
  • Players spawn but never move on the client. Confirm position is in the synchronizer’s replication list.
  • RPC errors in the Output panel. RPCs need matching node paths on every peer. Make sure players are named by peer ID and added under the same parent.
  • Join does nothing. Host first, then join. Both must use the same port.

What to build next

Movement on the client will feel slightly delayed, because nothing moves until the server answers. On your own computer that delay is tiny. Over a real network, you would add client-side prediction, which is its own topic.

From here, try adding a player name label synced the same way, a pickup that only the server can award, or a disconnect message. Keep the same rule for each: clients ask, the server decides, and the synchronizer reports the result.

Similar Posts

Leave a Reply

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