Guides · Godot 4 multiplayer

Player accounts and a database for Godot 4 multiplayer

Godot's high-level multiplayer (ENet or WebSocket) runs your gameplay. Your BackendGen.dev backend runs everything around it: player sign-in, saved player data, leaderboards and cloud saves, on your own server. This guide uses SceneMultiplayer's authentication, so your server knows exactly who each player is before peer_connected fires.

How it fits together

Players never tell your Godot server who they are: your backend does. The whole handoff is four steps:

  1. Game → your backendSign in. The player signs in with their gamertag and gets a session token (it lasts 30 days).
  2. Game → Godot serverConnect and send the token once, through auth_callback.
  3. Godot server → your backendCheck. POST /api/server/verify-session with the server's own key. The backend answers with the real gamertag, or says the token isn't valid.
  4. Godot serverLet in, or turn away: complete_auth(id) with the confirmed name, or disconnect_peer(id).
POST /api/server/verify-session      X-Server-Key: ubgs1.xxxx.yyyy
{ "token": "<the player's session>" }

-> { "success": true, "valid": true, "player": "Alice", "expiresAt": 1796222795561 }
-> { "success": true, "valid": false }          (made up, expired or from another backend)

Your backend never touches the gameplay traffic, and Godot's multiplayer never sees a password. With the Advanced licence, step 1 can also hand out a one-use join ticket for a particular server instead.

The steps

  1. Sign the player in

    Before connecting to the game server, sign in with the client that came with your backend:

    # your sign-in screen
    var r := await Backend.sign_in(gamertag)
    if r.ok:
    	Network.join_game("203.0.113.5", 7777)   # sends the session while connecting
    else:
    	print("Sign-in failed: ", r.error)

    The Backend autoload is the Godot client that comes with your backend (addons/<name>_backend).

  2. Give your server a key

    Your game server proves who it is with a server key that may only do what you allow. Make one with the sessions scope. With the Advanced licence: admin dashboard > Server keys. With any licence, from a terminal:

    curl -X POST https://api.yourgame.com/api/admin/server-keys \
      -H "Authorization: Bearer YOUR_ADMIN_PASSWORD" \
      -H "Content-Type: application/json" \
      -d '{"name":"Game servers","scopes":["sessions"]}'

    Or in PowerShell:

    (Invoke-RestMethod -Method Post -Uri https://api.yourgame.com/api/admin/server-keys `
      -Headers @{ Authorization = "Bearer YOUR_ADMIN_PASSWORD" } -ContentType "application/json" `
      -Body '{"name":"Game servers","scopes":["sessions"]}').key

    The key (ubgs1.…) is shown once. Give it to your server as environment variables when it starts:

    UBG_BACKEND_URL=https://api.yourgame.com UBG_SERVER_KEY=ubgs1.xxxx.yyyy ./MyGameServer.x86_64 --headless

    Never put the key (or your admin password) in a build that players download: anyone could pull it out.

  3. Add the network script

    network.gd

    extends Node
    ## network.gd - add as an autoload (Project Settings > Globals) named "Network".
    ## Players sign in with your backend first (Backend.sign_in), then call Network.join_game(...).
    ## Your dedicated server calls Network.host_game(...) and needs two environment variables:
    ##   UBG_BACKEND_URL=https://api.yourgame.com   UBG_SERVER_KEY=ubgs1.xxxx.yyyy   (never in a game build)
    
    signal joined(player: String)
    
    ## Server only: peer id -> gamertag, for everyone who passed the check.
    var players := {}
    
    ## Advanced: a one-use join ticket from Backend.fleet_join / matchmaking, sent instead of the session.
    var join_ticket := ""
    
    
    # ---------------- client ----------------
    func join_game(address: String, port: int) -> Error:
    	var sm := multiplayer as SceneMultiplayer
    	sm.auth_callback = func(_id: int, _data: PackedByteArray) -> void: pass   # the server sends nothing back
    	sm.auth_timeout = 10.0
    	if not sm.peer_authenticating.is_connected(_send_session):
    		sm.peer_authenticating.connect(_send_session)
    	var peer := ENetMultiplayerPeer.new()
    	var err := peer.create_client(address, port)
    	if err == OK:
    		multiplayer.multiplayer_peer = peer
    	return err
    
    
    func _send_session(id: int) -> void:
    	if id != 1:   # 1 is always the server
    		return
    	var sm := multiplayer as SceneMultiplayer
    	var proof: String = join_ticket if join_ticket != "" else Backend.player_token
    	sm.send_auth(1, proof.to_utf8_buffer())
    	sm.complete_auth(1)
    
    
    # ---------------- server ----------------
    func host_game(port: int, max_players: int = 32) -> Error:
    	var sm := multiplayer as SceneMultiplayer
    	sm.auth_callback = _check_session
    	sm.auth_timeout = 10.0
    	sm.peer_disconnected.connect(func(id: int) -> void: players.erase(id))
    	var peer := ENetMultiplayerPeer.new()
    	var err := peer.create_server(port, max_players)
    	if err == OK:
    		multiplayer.multiplayer_peer = peer
    	return err
    
    
    func _check_session(id: int, data: PackedByteArray) -> void:
    	var sm := multiplayer as SceneMultiplayer
    	var player := await _verify(data.get_string_from_utf8())
    	if not sm.get_authenticating_peers().has(id):
    		return   # they left while we asked
    	if player == "":
    		sm.disconnect_peer(id)
    		return
    	players[id] = player
    	sm.complete_auth(id)
    	joined.emit(player)
    
    
    ## Asks the backend who the session belongs to: the gamertag, or "" when it isn't a real session.
    func _verify(token: String) -> String:
    	var url := OS.get_environment("UBG_BACKEND_URL").trim_suffix("/") + "/api/server/verify-session"
    	var headers := PackedStringArray(["Content-Type: application/json", "X-Server-Key: " + OS.get_environment("UBG_SERVER_KEY")])
    	var http := HTTPRequest.new()
    	http.timeout = 8.0
    	add_child(http)
    	var err := http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify({"token": token}))
    	if err != OK:
    		http.queue_free()
    		return ""
    	var res: Array = await http.request_completed
    	http.queue_free()
    	if res[0] != HTTPRequest.RESULT_SUCCESS or res[1] != 200:
    		push_warning("[Network] verify-session: HTTP %d" % res[1])
    		return ""
    	var body: Variant = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8())
    	if body is Dictionary and body.get("valid", false):
    		return str(body.get("player", ""))
    	return ""

    Add network.gd as an autoload named Network (Project Settings > Globals). Your dedicated server calls Network.host_game(7777); players call Network.join_game(...) after signing in. The client sends its session as authentication data, the server asks your backend, and the player only becomes a connected peer if the session is real.

  4. Use who they are

    # anywhere on the server, e.g. inside an RPC a player called
    @rpc("any_peer")
    func say(text: String) -> void:
    	var player: String = Network.players.get(multiplayer.get_remote_sender_id(), "")
    	print(player, ": ", text)   # the gamertag your backend confirmed

    Every peer that passed the check is in Network.players (peer id to gamertag). Use it for name tags, chat, saving the player's data or posting their score. Your server can also post scores or read a player's cloud save directly: give its key the leaderboards or saves scope (Advanced).

Advanced: one-use join tickets

With the Advanced licence your servers register with the backend (a heartbeat every few seconds) and players ask the backend where to play. They get a ticket for one server that works once, for 60 seconds. The server redeems it and learns who the player is, so a ticket that's copied, reused or sent to another server is worthless. Matchmaking hands out the same tickets when a match is found.

# client: get a ticket for a server, then connect to it with the ticket
var r := await Backend.fleet_join("", "eu", "deathmatch")
if r.ok:
	Network.join_ticket = r.data.ticket          # _send_session sends this instead of the session
	Network.join_game(r.data.server.address, int(r.data.server.port))

network.gd already sends join_ticket instead of the session when it's set. Then, on the server:

## Server: redeem a join ticket instead of checking a session (the key needs the "fleet" scope).
## server_id is the id this server registers with in its heartbeat (see MULTIPLAYER-SERVICES.txt).
func _redeem(ticket: String, server_id: String) -> String:
	var url := OS.get_environment("UBG_BACKEND_URL").trim_suffix("/") + "/api/fleet/redeem"
	var headers := PackedStringArray(["Content-Type: application/json", "X-Server-Key: " + OS.get_environment("UBG_SERVER_KEY")])
	var http := HTTPRequest.new()
	add_child(http)
	http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify({"server": server_id, "ticket": ticket}))
	var res: Array = await http.request_completed
	http.queue_free()
	var body: Variant = JSON.parse_string((res[3] as PackedByteArray).get_string_from_utf8()) if res[1] == 200 else null
	return str(body.player) if body is Dictionary and body.has("player") else ""

The MULTIPLAYER-SERVICES.txt in your download explains heartbeats, zones and handing players over between servers.

Good to know

We ran every backend call on this page against a freshly generated backend: a real session, a made-up one, a wrong key, a key without the sessions scope and a backend that's down. The GDScript ran in Godot 4.6 as a headless ENet server and client: the real session joined, the made-up one was refused.

Questions

Does BackendGen.dev replace Godot's multiplayer?
No, it works alongside it. Godot's multiplayer runs the gameplay: moving players and syncing the match. Your backend runs everything around it: accounts, saved player data, leaderboards and cloud saves, plus matchmaking and your list of game servers with the Advanced licence.
Can a player pretend to be someone else on my Godot server?
Not with this set-up. The client only sends its session; the server asks your backend who that session belongs to and uses the name the backend gives back. A made-up, expired or copied-from-another-backend session is turned away.
Do I need the Advanced licence?
No. Checking a player's session works with every licence. Advanced adds one-use join tickets, matchmaking, a list of your game servers, leaderboards, cloud saves and more.

Other engines and libraries

Haven't got a backend yet?

Try the generator free with an account: fill in your settings and see every file you'd get. Buy a licence when you want to download.

Try the generator freeCompare with PlayFab & Firebase