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.
- Godot + Godot 4 multiplayer
- Dedicated or host servers
- Base or Advanced
- Run in Godot 4.6, checked against a generated backend
How it fits together
Players never tell your Godot server who they are: your backend does. The whole handoff is four steps:
- Game → your backendSign in. The player signs in with their gamertag and gets a session token (it lasts 30 days).
- Game → Godot serverConnect and send the token once, through
auth_callback. - Godot server → your backendCheck.
POST /api/server/verify-sessionwith the server's own key. The backend answers with the real gamertag, or says the token isn't valid. - Godot serverLet in, or turn away:
complete_auth(id)with the confirmed name, ordisconnect_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
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
Backendautoload is the Godot client that comes with your backend (addons/<name>_backend).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
sessionsscope. 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"]}').keyThe 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 --headlessNever put the key (or your admin password) in a build that players download: anyone could pull it out.
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 callNetwork.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.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 confirmedEvery 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 theleaderboardsorsavesscope (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
- Use https:// for
UBG_BACKEND_URLon a live server, so the key and sessions are encrypted on the way. The deploy guide sets that up. - The session crosses your game connection once. Most game transports don't encrypt by default. For a public game, switch on your transport's encryption if it has it, or use Advanced join tickets, which are worthless once used.
- Check at connect, not on every message. After the check the connection itself is the proof; there's no need to ask the backend again.
- If the backend can't be reached, the check fails and the player is turned away, so nobody gets in unchecked.
- Give each key only what it needs (
sessions, plusfleetwith tickets) and revoke a key the moment it leaks.
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