Multiplayer
Brain matches players and runs your game's dedicated servers. Every online match plays on a server of your game that Brain starts for it: the server holds the match's state and alone reports how it ended. Players sign in, find a match, and connect to that server.
Ways to play
| Way | Players get | Config |
|---|---|---|
| Ranked queue | Skill matchmaking with visible ratings, seasons and a ladder | [[matchmaking.queue]] with ranked = true |
| Casual queue | Practice matchmaking by a hidden rating | [[matchmaking.queue]] |
| Private match | Play friends by invite code, unrated | [matchmaking.private] |
| Open lobby | Create a lobby on a server, or pick one from a list | A server build |
[[matchmaking.queue]]
id = "ranked"
teams = 2 # teams times team_size players, at most 16
team_size = 1
host = "server" # your dedicated server
rating = "glicko2" # none, elo or glicko2
ranked = true # visible ratings; false keeps the rating hidden
guests = true # players signed in only as guests may queue
stats = ["goals"] # numbers your server reports per seat with the result
[[matchmaking.queue]]
id = "casual" # a bare queue: a hidden-rating 1v1 that guests may join
[matchmaking.private]
seats = 4 # the creator included, 2 by default
ase net add ranked,casual,private,lobbies adds these sections to an
existing game, and
ase new-game --multiplayer writes them into a new one. Nothing goes live
until your next release.
Your dedicated server
Your release publishes a Linux server build of your game alongside its clients, through the
servers release target. Brain starts it on its machines when players need a match or a lobby.
Regions and capacity are on your game's Settings page in the console, never in the repo.
Players and names
Online play needs the player signed in. The SDK signs in silently at startup with the platform's account where there is one (Steam, Game Center or Play Games), else with the credential this device kept, else as a new guest. Offer guests a way to link Apple or Google, so their account follows them.
- Never log, print or record player names or ids. Showing a name on screen is fine.
- When your game shows other players' names, it must let the player report a name and block a player (store rules). Brain ships no buttons for this: put them where your game shows names.
| Godot | |
|---|---|
| Signed in | Brain.account.signed_in |
| Link Apple or Google | Brain.account.link("apple") |
| Report a name | await Brain.account.report_name(name) |
| Block a player | await Brain.account.block(name) |
| List and lift blocks | blocked(), unblock(name) |
Blocked players are never matched together in your game's queues.
Find a match and connect
Queue the player, then connect to the match's server with a connection intent. While the server is
still starting, the intent answers unavailable or conflict: ask again shortly.
var binding
func _ready() -> void:
Brain.match.searching.connect(func(seconds: int) -> void: status.text = "Searching… %ds" % seconds)
Brain.match.matched.connect(_on_matched)
Brain.match.failed.connect(func(reason: String) -> void: status.text = "No match: %s" % reason)
Brain.match.ended.connect(_on_ended)
func _on_play_pressed() -> void:
Brain.match.queue("ranked")
func _on_matched(info: Dictionary) -> void:
var intent: Dictionary = await Brain.match.connection_intent(info.match)
while intent.get("error", "") in ["unavailable", "conflict"]:
await get_tree().create_timer(1.0).timeout
intent = await Brain.match.connection_intent(info.match)
if intent.has("error"):
return
binding = Brain.network.open_client(intent)
if binding.state() == "refused":
return
binding.resync_requested.connect(_on_resync)
binding.ready.connect(func(_peer: MultiplayerPeer, _admission: Dictionary) -> void: start_match(info))
binding.closed.connect(_on_closed)
binding.session().packet_received.connect(_on_packet)
func _on_resync(ticket) -> void:
apply_state(ticket.snapshot()) # the server's state for this player
ticket.complete()
matched gives match, seat, team and lobby. failed gives a reason: sign_in, busy,
not_allowed (guests may not queue there), unavailable (the queue is full), daily_limit (see
credits), network and others.
Brain.match.cancel() stops searching.
Play the match
Pump the connection around each frame, and send to the server, which is always peer 1:
func _physics_process(_delta: float) -> void:
if binding == null or binding.state() != "ready":
return
binding.begin_tick()
binding.session().send_packet(1, encode_input(), 0, "reliable_ordered")
binding.end_tick()
func _on_packet(_peer_id: int, payload: PackedByteArray, _channel: int, _mode: String) -> void:
apply_server_update(payload)
Delivery modes are reliable_ordered, unreliable_unordered and unreliable_sequenced, as the
connection negotiated them. binding.payload_capacity(mode) is the largest payload one packet
carries.
A lost connection closes the binding. To reconnect, ask for a fresh intent and open a new one: each
intent admits once. The server sends its current state through resync_requested again, so apply
that and never replay old input.
How the match ends
func _on_ended(result: Dictionary) -> void:
if result.outcome == "final":
show_result(result.place, result.get("ratings", []))
A match counts only when outcome is final. conflict and abandoned rate nothing. The result
also holds places by seat, this player's seat and place, ratings in ranked matches and the
stats the server reported.
On the server
Brain starts your server build with Brain.network.launched() true. Open the authority, give each
admitted player the match's state, and report the result when the match is decided:
var authority
func _ready() -> void:
if not Brain.network.launched():
return
authority = Brain.network.open_authority()
authority.resync_requested.connect(func(ticket) -> void:
ticket.complete(encode_state_for(ticket.seat())))
authority.session().player_ready.connect(_on_player_ready)
authority.session().packet_received.connect(_on_input)
func _physics_process(_delta: float) -> void:
if authority and authority.state() == "ready":
authority.begin_tick()
simulate()
authority.end_tick()
func _on_match_decided(places: Array) -> void:
var result: Dictionary = await Brain.match.report_result(places)
while result.get("error", "") == "uncertain":
result = await Brain.match.report_result(places)
player_ready(peer_id, seat) is a seated player. places are by seat, lower is better, and
teammates share theirs: [0, 0, 1, 1] means seats 0 and 1 won. A queue with stats reports them
too, each by seat: report_result([0, 1], {"goals": [2, 1]}). On uncertain, call again with the
same places. Players don't report; every player's ended carries the settled result.
Brain times every server tick on its own and reports the server's load. Brain.server_load(share)
overrides it, for a game that times its own tick.
Private matches
await Brain.match.private_session_create() answers the invite code to show, large, and the
match's state. Friends call await Brain.match.private_session_join(code). The creator starts the
match with await Brain.match.private_session_start(match_id) once two or more are in, and
private_session_view(match_id) reads where it stands. Once its phase is assigned, everyone
connects with connection_intent as for a queued match. Private matches are unrated.
Open lobbies
For games where players create or browse lobbies, Brain.lobbies asks for one on a server and
players connect to it over ENet:
func _ready() -> void:
Brain.lobbies.server_ready.connect(func(address: String, port: int, _owner_token: String) -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_client(address, port)
multiplayer.multiplayer_peer = peer)
Brain.lobbies.listed.connect(show_lobbies)
Brain.lobbies.failed.connect(func(reason: String) -> void: status.text = reason)
func _on_create_pressed() -> void:
Brain.lobbies.create("duel")
func _on_browse_pressed() -> void:
Brain.lobbies.list()
On the server (Brain.is_server()), listen on Brain.server_port() and tell Brain how full it is
whenever players come or go:
const SEATS := 8
func _ready() -> void:
var peer := ENetMultiplayerPeer.new()
peer.create_server(Brain.server_port(), SEATS)
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_report.unbind(1))
multiplayer.peer_disconnected.connect(_report.unbind(1))
_report()
func _report() -> void:
Brain.server_status(multiplayer.get_peers().size(), SEATS, true, {"level": "arena"})
info holds a few short labels players see when browsing lobbies. Lobbies need the player signed
in; otherwise they fail with sign_in.
Ratings
var rating: Dictionary = await Brain.match.rating("ranked")
var ladder: Dictionary = await Brain.match.leaderboard("ranked", true) # around the player
A casual queue's rating stays hidden: never show it or hint at it.
Test locally
Run two windowed instances with separate players:
godot --path . -- --brain-profile=a
godot --path . -- --brain-profile=b
Add --brain-net-sim=lag:60,jitter:5,loss:1 after -- on both to play over a bad network in debug
builds. To put any other peer behind the same simulation, wrap it:
multiplayer.multiplayer_peer = Brain.net_sim(peer).