Skip to content

Instantly share code, notes, and snippets.

@jamonholmgren
Last active November 25, 2024 08:06
Show Gist options
  • Save jamonholmgren/dcab7d30d54f7693cacdcb5241fbe050 to your computer and use it in GitHub Desktop.
Save jamonholmgren/dcab7d30d54f7693cacdcb5241fbe050 to your computer and use it in GitHub Desktop.

Lobby API Documentation

The Lobby node handles Godot 4.x multiplayer game session management, including hosting, joining, and basic player state synchronization.

You can use this to

Setup

  1. Copy all 3 files (Lobby.gd, LobbyClient.gd, and LobbyServer.gd) into your project
  2. Make Lobby.gd a Global in Project Settings and name it "Lobby"
  3. Open Lobby.gd and configure LOBBY_SETTINGS

Signals

Generally speaking, in your game you'll be subscribing to the Lobby's signals to capture events like players joining and leaving and more.

Client-side Events

Note that pid is your local player's ID, and sid is the ID of the player who sent the message. If either is equal to Lobby.server_id(), then the message is from the server itself.

  • connection_succeeded(pid: int) - Emitted when successfully connected to a server
  • connection_failed() - Emitted when connection attempt fails
  • connection_refused(message: String) - Emitted when server refuses connection (e.g. version mismatch)
  • connection_ended() - Emitted when connection to server ends
  • player_connected(pid: int) - Emitted when any player connects
  • player_joined(pid: int, player: Dictionary) - Emitted when player completes joining process
  • i_joined(pid: int) - Emitted when local player completes joining process
  • player_updated(pid: int, player: Dictionary) - Emitted when player info is updated
  • player_disconnected(pid: int) - Emitted when player disconnects
  • player_left(pid: int, player: Dictionary, message: String) - Emitted when player properly leaves
  • i_left(pid: int, message: String) - Emitted when local player leaves
  • player_messaged(sid: int, data: Dictionary) - Emitted when receiving a message from another player. sid is the ID of the player who sent the message.
  • host_commanded(data: Dictionary) - Emitted when host sends command
  • game_state_updated(new_state: Dictionary) - Emitted when non-player state is updated. You can access just the new state in the event argument, or the full merged state in Lobby.game_state.
  • all_players_ready() - Emitted when all players become ready
  • all_players_not_ready() - Emitted when any player becomes not ready

Host-only Events

  • server_started() - Emitted when server starts successfully
  • server_shutdown(message: String) - Emitted when server shuts down
  • server_discovery_started() - Emitted when server starts broadcasting presence
  • server_discovery_failed(error: int) - Emitted when server fails to start broadcasting
  • server_discovery_stopped() - Emitted when server stops broadcasting
  • player_messaged_host(sid: int, data: Dictionary) - Emitted when the host receives a message from a player

Functions

Server Operations

Lobby.host_game()

func host_game() -> void

Start hosting a game server. Also joins the server as the host.

Lobby.end_hosted_game()

func end_hosted_game() -> void

End the currently hosted game. Only works if called by the host.

Client Operations

Lobby.refresh_servers()

func refresh_servers(callback: Callable) -> void

Search for local servers. Callback will receive Dictionary of found servers that looks like this:

{
  "192.168.0.52": {
    "game_name": "Thunderbird",
    "game_version": "1.4.2",
    "server_name": "Jamon's Server",
    "port": "4923"
   }
  "192.168.0.53": {
    "game_name": "Thunderbird",
    "game_version": "1.4.3",
    "server_name": "Mike's Server",
    "port": "4925"
   }
}

You can then use this info to display servers:

func refresh():
  Lobby.refresh_servers(func (new_servers: Dictionary):
    refresh_server_list(new_servers)
  )

func refresh_server_list(servers: Dictionary):
  var info = "Servers:\n"
  if servers.size() == 0:
    info += "No servers found"
    return

  for ip in servers:
    var server = servers[ip]
    var compatible = server.game_version == ProjectSettings.get_setting("application/config/version")
    if compatible:
      info += "" + server.server_name + ": " + ip + "\n"
    else:
      info += "" + server.server_name + " (wrong game version: " + server.game_version + ")" + "\n"

This, of course, is a very simplistic example.

Lobby.local_servers()

func local_servers() -> Dictionary

Get the same info as above, but without refreshing first.

Lobby.join_server(addr)

func join_server(server_address: String) -> void

Connect to server at given IP address.

Lobby.leave_server(msg)

func leave_server(message: String) -> void

Leave current server with goodbye message, such as "Jamon left the lobby."

Lobby.update(info)

func update(new_info: Dictionary) -> void

Update local player information. Changes will automatically sync to all clients and the server.

Lobby.ready_up()

func ready_up() -> void

Mark local player as ready to start the game. Changes will automatically sync to all clients and the server.

Lobby.not_ready()

func not_ready() -> void

Mark local player as not ready. Changes will automatically sync to all clients and the server.

Lobby.broadcast(data)

func broadcast(data: Dictionary) -> void

Send message to all connected players. Use this for things like chat messages and other non-state events.

Lobby.command(data)

func command(data: Dictionary) -> void

Host only: Send command to all players. Use this for things like "Start game" or "Change scene". It takes a dictionary.

Example:

Lobby.command({ "scene": "World" })

You'd handle this on the client by watching for on_host_command signals (see below) and updating the current scene or whatever.

Lobby.command_id(pid, data)

func command_id(pid: int, data: Dictionary) -> void

Host only: Same as command, but to a specific player ID.

State Queries

Lobby.id()

func id() -> int

Get local player's ID.

Lobby.server_id()

func server_id() -> int

Get server's ID.

Lobby.is_host()

func is_host() -> bool

Check if local player is the host.

Lobby.is_in_server()

func is_in_server() -> bool

Check if connected to a server.

Lobby.is_client()

func is_client() -> bool

Check if connected as client (not host).

Lobby.all_ready()

func all_ready() -> bool

Check if all players are marked as ready.

State Objects

Lobby.me - Local Player State

This is where you can access your player's synchronized state.

{
  "name": "Unknown Player",
  "ready": false,
  "game_version": String  # Automatically set from project settings
}

Additional fields can be added in Lobby.gd and will sync automatically.

You usually wouldn't modify this directly, as it won't sync automatically if you do. Instead, use Lobby.update(info) to update your player state.

Lobby.players - Player States

This holds all connected players' states (including a copy of your local player state), keyed by peer ID:

var players: Dictionary = {
  1: {
    "name": "Player 1",
    "ready": false,
    "game_version": "1.0.0"
  },
  2: {
    "name": "Player 2",
    "ready": true,
    "game_version": "1.0.0"
  }
}

Lobby.game_state - Non-Player Game State

This holds any other game state that isn't player-specific. You can sync this state using Lobby.update_game_state(new_state).

var game_state: Dictionary = {
  # Add any game-specific state here
  "current_map": "forest",
  "game_mode": "capture_the_flag",
  # etc...
}

Settings

const LOBBY_SETTINGS := {
  server_name = "Lobby",    # Default server name
  game_port = 6464,         # Main game server port
  discovery_port = 6463,    # Server discovery broadcast port
  max_players = 8,          # Maximum allowed players
  client_discovery_port = 6462,  # Port for client discovery
  refresh_packet = "LOBBY_CLIENT",  # Discovery packet identifier
  connection_timeout = 3    # Seconds to wait for connection
}
# This is the main Lobby, which is customized per game
# Make sure to make it a Global in Game Settings and name it "Lobby".
# There's no built-in logic here unless you add it; instead,
# it creates a LobbyClient and LobbyServer and tells them
# about each other and what information they will be syncing.
#
# All rpc methods are in this instance.
extends Node
# Client Signals *******************************************************************
signal connection_succeeded(pid: int)
signal connection_failed()
signal connection_refused(message: String)
signal connection_ended()
signal player_connected(pid: int)
signal player_joined(pid: int, player: Dictionary)
signal i_joined(pid: int)
signal player_updated(pid: int, player: Dictionary)
signal player_disconnected(pid: int)
signal player_left(pid: int, player: Dictionary, message: String)
signal i_left(pid: int, message: String)
signal player_messaged(sid: int, data: Dictionary)
signal player_messaged_host(sid: int, data: Dictionary)
signal host_commanded(data: Dictionary)
signal game_state_updated(changes: Dictionary, full_state: Dictionary)
signal all_players_ready()
signal all_players_not_ready()
# Server Signals *******************************************************************
signal server_started()
signal server_shutdown(message: String)
signal server_discovery_started()
signal server_discovery_failed(error: int)
signal server_discovery_stopped()
const LOBBY_SETTINGS := {
server_name = "Lobby",
game_port = 6464,
discovery_port = 6463,
max_players = 8,
client_discovery_port = 6462,
refresh_packet = "LOBBY_CLIENT",
connection_timeout = 3 # seconds
}
var client: LobbyClient = LobbyClient.new()
var server: LobbyServer = LobbyServer.new()
# State ***************************************************************************
var game_version: String = ProjectSettings.get_setting("application/config/version")
var players: Dictionary = {}
var game_state: Dictionary = {}
var me := {
"name": "Unknown Player",
"ready": false,
"game_version": ProjectSettings.get_setting("application/config/version")
# Add other fields as needed here; they'll be synced to other players on update
}
var lobby_ready := false
# Lobby Setup *********************************************************************
func _ready():
client.settings = LOBBY_SETTINGS
client.connection_succeeded.connect(_on_connection_succeeded)
client.connection_failed.connect(_on_connection_failed)
client.connection_ended.connect(_on_connection_ended)
client.player_connected.connect(_on_player_connected)
client.player_disconnected.connect(_on_player_disconnected)
add_child(client)
server.settings = LOBBY_SETTINGS
server.server_started.connect(_on_server_started)
server.server_shutdown.connect(_on_server_shutdown)
server.discovery_started.connect(_on_discovery_started)
server.discovery_stopped.connect(_on_discovery_stopped)
server.discovery_failed.connect(_on_discovery_failed)
server.player_connected.connect(_on_player_connected)
server.player_disconnected.connect(_on_player_disconnected)
add_child(server)
# Server Actions *******************************************************************
func host_game(hostname: String):
server.host_game(hostname)
# Kick off the join process for ourselves
on_player_joined.rpc_id(id(), me)
func start_discovery():
server.start_discovery()
func stop_discovery():
server.stop_discovery()
func end_hosted_game():
server.shutdown("Game ended by host")
# Client Actions *******************************************************************
# Refresh the list of local servers
func refresh_servers(callback: Callable):
client.refresh_local_servers(callback)
# Get the list of local servers (ip addresses)
func local_servers() -> Dictionary:
return client.local_servers
# Join a server by IP address
func join_game(server_address: String):
client.join_server(server_address)
# Leave the current server
func leave_game(message: String):
if is_host(): server.shutdown(message)
client.leave_server()
player_left.emit(id(), me, message)
i_left.emit(id(), message)
# Request the current state from the connected server
func request_state():
on_request_state_server.rpc_id(server.ID)
# Update our info on the server
func update(new_info: Dictionary):
me.merge(new_info, true)
on_player_updated.rpc_id(server.ID, new_info)
func update_game_state(new_state: Dictionary):
# This updates the server with other game state that isn't a player's state.
# We don't check if we're the host here because anyone can update the other state.
# so you'll need to do your own checks before firing this, such as checking
# Lobby.is_host() to make sure you're allowed to update that particular piece of state.
game_state.merge(new_state, true)
on_game_state_update.rpc(new_state, game_state)
# Set ourselves to ready
func ready_up():
update({ "ready": true })
# Set ourselves to not ready
func not_ready():
update({ "ready": false })
# Returns if all players are ready
func all_ready() -> bool:
if players.is_empty(): return false
for player in players.values():
if player.ready != true: return false
return true
# Check if all players are ready
func check_ready():
if all_ready():
# Ready, and were already ready
if lobby_ready: return
# We just became ready, so emit the signal
lobby_ready = true
all_players_ready.emit()
else:
# We are no longer ready? Emit the not ready signal
if lobby_ready: all_players_not_ready.emit()
lobby_ready = false
# Send a message to a specific player
func send(pid: int, data: Dictionary):
on_player_message.rpc_id(pid, data)
# Send a message to all players
func send_all(data: Dictionary):
on_player_message.rpc(data)
# Send a message to the server
func send_server(data: Dictionary):
on_player_message_host.rpc_id(server.ID, data)
# As the host, command a specific player to do something
func command(pid: int, data: Dictionary):
if not is_host(): return
on_host_command.rpc_id(pid, data)
# As the host, command other players to do something (like change scene, start game, etc.)
func command_all(data: Dictionary):
if not is_host(): return
on_host_command.rpc(data)
# Getting state *******************************************************************
func id() -> int: return client.id()
func server_id() -> int: return server.ID
func is_host() -> bool: return client.is_host()
func is_in_server() -> bool: return client.is_in_server()
func is_client() -> bool: return client.is_client()
# Signal Handlers ***************************************************************
func _on_connection_succeeded(pid: int):
connection_succeeded.emit(pid)
func _on_connection_failed():
connection_failed.emit()
func _on_connection_ended():
connection_ended.emit()
func _on_server_started():
server_started.emit()
func _on_server_shutdown(message: String):
server_shutdown.emit(message)
func _on_discovery_started():
server_discovery_started.emit()
func _on_discovery_failed(error: int):
server_discovery_failed.emit(error)
func _on_discovery_stopped():
server_discovery_stopped.emit()
func _on_player_connected(pid: int):
print("Player connected: ", pid)
player_connected.emit(pid)
# When a new player joins, we send them the current state of the lobby
on_server_state.rpc_id(sid(), players, game_state)
func _on_player_disconnected(pid: int):
player_disconnected.emit(pid)
# Tell everyone that the player left, since they left without saying goodbye
on_player_left.rpc(pid, "Player disconnected")
# Endpoints ***************************************************************
func sid() -> int: return client.sid()
# When the server reports its state, we update our local state
# and then tell everyone about ourselves
@rpc("authority", "reliable", "call_local")
func on_server_state(server_players: Dictionary, server_game_state: Dictionary, server_game_version: String):
print("on_server_state: ", server_players, server_game_state, server_game_version)
if game_version != server_game_version:
var reason = "Server is a different version: ours %s != server's %s" % [game_version, server_game_version]
leave_game(reason)
connection_refused.emit(reason)
return
# Merge the server's state into ours
players.merge(server_players, true)
game_state.merge(server_game_state, true)
# If we're new, tell everyone about ourselves!
if not sid() in players: on_player_joined.rpc(me)
@rpc("any_peer", "reliable", "call_local")
func on_game_state_update(new_state: Dictionary):
game_state.merge(new_state, true)
game_state_updated.emit(new_state)
@rpc("any_peer", "reliable", "call_local")
func on_request_state_server():
if not is_host(): return
on_server_state.rpc_id(sid(), players, game_state)
@rpc("any_peer", "reliable", "call_local")
func on_player_joined(info: Dictionary):
var pid = sid() if sid() > 0 else id()
print("Lobby.on_player_joined: ", pid, info)
# New player joined!
players[pid] = info
player_joined.emit(pid, info)
if pid == id(): i_joined.emit(pid)
@rpc("any_peer", "reliable", "call_local")
func on_player_updated(info: Dictionary):
if not sid() in players: return on_player_joined(info)
# Existing player updated some of their info
players[sid()].merge(info, true)
player_updated.emit(sid(), players[sid()])
check_ready()
@rpc("any_peer", "reliable", "call_local")
func on_player_left(pid: int, message: String):
if not pid in players: return
var old_player = players.get(pid)
players.erase(pid)
player_left.emit(pid, old_player, message)
if pid == id(): i_left.emit(pid, message)
@rpc("authority", "reliable", "call_local")
func on_host_command(data: Dictionary):
host_commanded.emit(data)
@rpc("any_peer", "reliable", "call_local")
func on_player_message(data: Dictionary):
player_messaged.emit(sid(), data)
@rpc("any_peer", "reliable", "call_local")
func on_player_message_host(data: Dictionary):
if not is_host(): return
player_messaged_host.emit(sid(), data)
# This is for any client; no host-specific stuff in here
class_name LobbyClient extends Node
signal connection_succeeded()
signal connection_failed()
signal connection_ended()
signal player_connected(pid: int)
signal player_disconnected(pid: int)
# for discovering local servers
var client_discovery_broadcast: PacketPeerUDP = PacketPeerUDP.new()
var local_servers = {}
var refreshing := false
# Lobby will set these on init
var settings: Dictionary
func _ready():
multiplayer.connected_to_server.connect(_on_connected_ok)
multiplayer.connection_failed.connect(_on_connected_fail)
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
multiplayer.server_disconnected.connect(_on_server_disconnected)
multiplayer.multiplayer_peer = null
func _exit_tree():
if client_discovery_broadcast.is_bound(): client_discovery_broadcast.close()
func id() -> int:
return multiplayer.get_unique_id()
func sid() -> int:
return multiplayer.get_remote_sender_id()
func is_host():
return multiplayer.multiplayer_peer != null and multiplayer.is_server()
func is_in_server() -> bool:
return multiplayer and multiplayer.multiplayer_peer != null and multiplayer.multiplayer_peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
func is_client() -> bool:
return not is_host() and is_in_server()
func join_server(ip_address: String):
var peer := ENetMultiplayerPeer.new()
var error := peer.create_client(ip_address, settings.game_port)
if error != OK: return error
multiplayer.multiplayer_peer = peer
func leave_server():
multiplayer.multiplayer_peer = null
connection_ended.emit()
func refresh_local_servers(callback: Callable, retry = 0):
refreshing = true
local_servers.clear()
var error := client_discovery_broadcast.bind(settings.discovery_port)
if error != OK:
refreshing = false
callback.call(local_servers)
return
client_discovery_broadcast.set_broadcast_enabled(true)
client_discovery_broadcast.set_dest_address("255.255.255.255", settings.discovery_port)
client_discovery_broadcast.put_packet(settings.refresh_packet.to_utf8_buffer())
await Delay.wait(0.2 * (retry + 1))
while client_discovery_broadcast.get_available_packet_count() > 0:
var response := client_discovery_broadcast.get_packet().get_string_from_utf8()
var server_ip := client_discovery_broadcast.get_packet_ip()
if server_ip == "": continue
# ignore our own echoes
if response == settings.refresh_packet: continue
var server_info_raw = response.split(";")
# Check if we have all required fields
if server_info_raw.size() < 4:
print("Invalid server response format: ", server_info_raw, response)
continue
var server_info = {
"game_name": server_info_raw[0],
"game_version": server_info_raw[1],
"server_name": server_info_raw[2],
"port": server_info_raw[3]
}
local_servers[server_ip] = server_info
client_discovery_broadcast.close()
refreshing = false
# Refresh again up to 3 times, sometimes it takes a bit longer
if local_servers.size() <= 0 and retry < 3:
refresh_local_servers(callback, retry + 1)
else:
callback.call(local_servers)
func _on_connected_ok():
# When we connect, the server will ask us to send our player information
# Because of that, we don't want to get ahead of ourselves too much
# We'll wait for the server to do that, but will tell the game that we've connected
connection_succeeded.emit()
func _on_connected_fail():
multiplayer.multiplayer_peer = null
connection_failed.emit()
func _on_player_connected(pid: int):
player_connected.emit(pid)
func _on_player_disconnected(pid: int):
player_disconnected.emit(pid)
func _on_server_disconnected():
leave_server()
class_name LobbyServer extends Node
# Signals ************************************************************************
signal server_started
signal server_shutdown
signal discovery_started
signal discovery_failed(error: int)
signal discovery_stopped
signal player_connected(pid: int)
signal player_disconnected(pid: int)
# Settings ************************************************************************
var settings: Dictionary
var server_name: String = "Unknown"
var game_name: String = ProjectSettings.get_setting("application/config/name")
# Constants **********************************************************************
const ID = 1 # Server ID is always 1
# Not technically a constant, but it might as well be
var game_version = ProjectSettings.get_setting("application/config/version")
# Servers *********************************************************************
var discovery_server: PacketPeerUDP = PacketPeerUDP.new()
# Lifecycle **********************************************************************
func _ready():
setup_multiplayer()
func _process(_delta):
_check_for_clients_searching()
func _exit_tree():
stop_discovery()
# Setup ************************************************************************
func setup_multiplayer():
multiplayer.peer_connected.connect(_on_player_connected)
multiplayer.peer_disconnected.connect(_on_player_disconnected)
# Actions ********************************************************************
func host_game(hostname: String):
server_name = hostname
# shut down server if it's currently running
if multiplayer.is_server(): shutdown("hosting a new game")
var game_server: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
var error = game_server.create_server(settings.game_port, settings.max_players)
if error != OK: return p("Error hosting game: ", error)
# Set up the multiplayer peer
multiplayer.multiplayer_peer = game_server
# Set up listener for clients trying to find servers
start_discovery()
# Ready to serve, m'lord
server_started.emit()
func shutdown(message: String):
# Only shutdown if we're the host and are actually connected
if not multiplayer.is_server(): return
if multiplayer.multiplayer_peer == null: return
stop_discovery()
disconnect_all_players()
# Now disconnect ourselves and the server itself
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
# Tell everyone we're done
server_shutdown.emit(message)
func disconnect_all_players():
# Gracefully disconnect all the players connected to multiplayer_peer
for player_id in multiplayer.get_peers():
if player_id == ID: continue # don't disconnect us yet
multiplayer.multiplayer_peer.disconnect_peer(player_id)
func start_discovery():
# Already bound?
if discovery_server.is_bound(): return
# Bind to the discovery port
var result = discovery_server.bind(settings.discovery_port)
if result == OK: discovery_started.emit()
else: discovery_failed.emit(result)
# Start looking for packets
set_process(true)
func stop_discovery():
if not discovery_server.is_bound(): return
discovery_server.close()
discovery_stopped.emit()
# Stop looking for packets
set_process(false)
# Utilities **********************************************************************
# Checks if there are clients out there searching for our server
func _check_for_clients_searching():
if not discovery_server.is_bound(): return
if discovery_server.get_available_packet_count() == 0: return
var packet_raw = discovery_server.get_packet()
var client_ip = discovery_server.get_packet_ip()
var client_port = discovery_server.get_packet_port()
var packet = packet_raw.get_string_from_utf8()
if packet != settings.refresh_packet: return
var response = (game_name + ";" + game_version + ";" + server_name + ";" + settings.game_port)
print("Sending response to ", client_ip, ":", client_port, " - ", response)
discovery_server.set_dest_address(client_ip, client_port)
discovery_server.put_packet(response.to_utf8_buffer())
# Get the local ipv4 addresses to broadcast to clients
func get_local_ipv4_addresses() -> Array:
var ipv4_addresses = []
for address in IP.get_local_addresses():
if not is_good_address(address): continue
ipv4_addresses.append(address)
return ipv4_addresses
# Callbacks **********************************************************************
func _on_player_connected(pid: int):
player_connected.emit(pid)
func _on_player_disconnected(pid: int):
player_disconnected.emit(pid)
# Helper functions ***************************************************************
# Checks if an IP address is probably a good one to broadcast to clients
func is_good_address(address: String) -> bool:
if not address.is_valid_ip_address(): return false
if address.begins_with("127."): return false
if address.split(".").size() != 4: return false
return true
# Print helper with null return
func p(m1, m2 = null, m3 = null, m4 = null, m5 = null, m6 = null, m7 = null, m8 = null):
print(m1, m2, m3, m4, m5, m6, m7, m8)
return null
# Multiplayer endpoints ***********************************************************
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment