SDK / Godot

Multiplayer

StableGodot 4.7+

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).