⌘K
↑↓ Navigate ↵ Open Esc Close
Tier 6: Realtime & Advanced Architecture

Where the harder design decisions live

Live networked movement, catching up late joiners, and account architectures that go beyond one player/one save file: the most structurally complex material in the series.

Tier 6Relay24 / 29

Multiplayer / Relay: real-time movement without running your own server

Lobbies hands you the host/port/passcode this connects to; Join-in-Progress builds directly on sendToPlayers.

brainCloud's relay is a low-latency multiplayer channel that opens once a lobby reaches ROOM_READY: the event already carries everything needed to connect, host, port, passcode, lobby id. Three ways to send are available: to one player, to a bitmask of players, or to everyone, over WebSocket, TCP, or UDP depending on the game and platform. brainCloud's servers handle the fan-out; there's no game server to provision or scale. Pick a protocol and start sending bytes.

How it works

  1. Connect using what the lobby already provided. ROOM_READY's payload carries the host, port, and passcode; call connect(protocol, host, port, passcode, lobbyId, callback) and that opens the relay socket.
  2. Pick a protocol per platform, but respect the forced overrides. UDP gives the lowest latency for twitch gameplay, TCP guarantees order, and WebSocket is what works in a browser. The reference apps let the player choose, except on GameLift and i3D-hosted servers, which force WebSocket regardless of the client's pick, because the fan-out infrastructure behind them doesn't support raw TCP/UDP from the client. The browser case isn't optional: the JS relay client only ever opens a WebSocket. There's no TCP/UDP option in-browser at all.
  3. Three ways to send are available. send(data, toNetId, ...) targets one peer by its relay-assigned net id. sendToPlayers(data, playerMask, ...) targets an arbitrary bitmask of peers (bit N = net id N). sendToAll(data, ...) (same as TO_ALL_PLAYERS) reaches everyone except the sender. Every send takes reliable/ordered/channel, and four priority channels are available (HIGH_PRIORITY_1, HIGH_PRIORITY_2, NORMAL_PRIORITY, LOW_PRIORITY). Movement can stay unordered-but-frequent on one channel while a rare reliable event uses another, so neither blocks the other.
  4. Resolve identity, not just net id. getNetIdForCxId/getCxIdForNetId map the compact numeric id used on the wire back to the actual player. That resolution should happen at receive time, since the net id table gets torn down at endMatch.
  5. Ping is per-connection, and the SDK doesn't share it automatically. getPing() only reports the local round-trip time; it isn't broadcast to other players. Showing every player's ping, as the reference apps do, requires broadcasting it as an ordinary app-level relay message.

Code

C++: connect + send
pBCWrapper->getRelayService()->registerRelayCallback(&bcRelayCallback);
pBCWrapper->getRelayService()->registerSystemCallback(&bcRelaySystemCallback);
auto protocol = (gameliftOrI3d) ? BrainCloud::eRelayConnectionType::WS : settings.protocol;
pBCWrapper->getRelayService()->connect(protocol, state.server.host, port,
                                        state.server.passcode, state.server.lobbyId,
                                        &bcRelayConnectCallback);

// send to a bitmask of players
auto str = Json::FastWriter().write(json);
pBCWrapper->getRelayService()->sendToPlayers(
    (const uint8_t *)str.data(), (int)str.length(),
    playerMask, /*reliable*/true, /*ordered*/false, (BrainCloud::eRelayChannel)0);
JavaScript: protocol selection + ping broadcast
// Priority: gamelift → i3d (both force WS) → user-selected protocol
if (ports.gamelift) { port = ports.gamelift; ssl = false }
else if (ports.i3d) { port = ports.i3d; ssl = false }
else { ssl = this.state.relayProtocol === 'wss'; port = ports.ws }

// app-level ping broadcast, every 2s: the SDK does not do this for you
pingInterval = setInterval(() => {
  const msg = { op: 'relay_ping', data: { ping: this.bc.relay.getPing() } }
  this.bc.relay.sendToAll(Buffer.from(JSON.stringify(msg), 'ascii'), false, false, this.bc.relay.CHANNEL_HIGH_PRIORITY_1)
}, 2000)
Godot (C#): masked send for "shockwaves only to checked players"
_brainCloudWrapper.RelayService.SendToAll(jsonBytes, _sendReliable, _sendOrdered, _sendChannel); // movement
_brainCloudWrapper.RelayService.SendToPlayers(jsonBytes, BuildSendMask(), true, false, _sendChannel); // shockwave, masked

ulong BuildSendMask() // one bit per allowed recipient, resolved cxId -> netId
{
    ulong mask = 0;
    foreach (var cxId in _allowSendTo.Keys)
        if (_allowSendTo[cxId]) mask |= 1ul << (int)RelayService.GetNetIdForCxId(cxId);
    return mask;
}
Godot (GDScript): masked send for "shockwaves only to checked players"
AppState.bc.relay_service.send(data, BrainCloudRelay.TO_ALL_PLAYERS, reliable, ordered, channel) # movement

var mask := 0 # one bit per allowed recipient, resolved cxId -> netId
for cx in _send_to:
    if _send_to[cx] and _cx_to_net_id.has(cx):
        mask |= 1 << int(_cx_to_net_id[cx])
AppState.bc.relay_service.send_to_players(data, mask, true, false, channel) # shockwave, masked

Gotchas worth knowing up front

  • Resolve cxId ↔ netId at receive time, not connect time. The mapping gets torn down on endMatch, so a value cached early and used after the match ends resolves to nothing.