Tier 6Relay25 / 29
Join-in-Progress: catching up a late joiner in two messages
Multiplayer / Relay's masked sendToPlayers; the state being resynced is tier 1 from Persistent State.
Join-in-progress needs exactly one new idea on top of everything in the Relay article: detect that a specific new peer just connected, and resend that peer, and only that peer, whatever state it missed. brainCloud's relay system events provide that connect signal directly. The resync itself is plain application-level messages over the same sendToPlayers call already used for gameplay. There's no separate "join in progress" API to learn.
How it works
- Watch the relay's CONNECT system event, not the lobby's MEMBER_JOIN. The lobby fires MEMBER_JOIN when someone joins the lobby, which happens before that peer is actually connected to the relay. The relay's own system callback fires CONNECT only once the peer is genuinely on the wire. That's the signal to act on.
- Only the host resyncs, and only to that one peer. On CONNECT, the host resolves the new peer's net id, builds a single-bit mask (1 << netId), and sends a game_start message (authoritative match start time plus round number) and a splotch_sync message (the accumulated canvas). Both go via sendToPlayers, not sendToAll: nobody else needs either message.
- Chunk the resync. The full canvas can exceed a single relay packet's size budget, so it's split into batches under ~900 bytes, comfortably below the relay's own ~1024-byte cap. The first batch is flagged (first: true) so the receiver clears its canvas before appending; every batch after that just appends.
- Use reliable: true, ordered: false, and know why. Dropping a chunk of the canvas isn't acceptable, but the chunks don't need to arrive in order: each batch is self-contained, and only the first one triggers a clear.
- Resolve netId → cxId at receive time, not when the mask is built. The mapping is torn down at endMatch, so anything needed later should be captured into the message payload itself, not left as a lookup for after the fact.
Code
C++
// relay system callback
else if (json["op"].asString() == "CONNECT") // new player joined mid-game
{
if (state.lobby.ownerCxId == state.user.cxId && state.gameStartTime != 0)
{
const auto &cxId = json["cxId"].asString();
auto netId = pBCWrapper->getRelayService()->getNetIdForCxId(cxId);
uint64_t mask = (uint64_t)1 << (uint64_t)netId;
sendGameStartToMask(mask);
sendSplotchSyncToMask(mask); // chunked, host -> just this one player
}
}
// chunked resend, ~900-byte batches
pBCWrapper->getRelayService()->sendToPlayers(
(const uint8_t *)str.data(), (int)str.length(),
mask, /*reliable*/true, /*ordered*/false, (BrainCloud::eRelayChannel)0);JavaScript
} else if (json.op === 'CONNECT') {
if (state.lobby.ownerCxId === state.user.cxId && state.screen === 'game') {
const newPlayerNetId = this.bc.relay.getNetIdForCxId(json.cxId)
const mask = Math.pow(2, newPlayerNetId)
this.sendGameStartToMask(mask)
this.sendSplotchSyncToMask(mask)
}
}
sendSplotchSyncToMask (mask) {
const MAX_BYTES = 900
const sendBatch = (first, batch) => {
const msg = { op: 'splotch_sync', data: { first, splotches: batch } }
this.bc.relay.sendToPlayers(Buffer.from(JSON.stringify(msg), 'ascii'), mask, true, false, this.bc.relay.CHANNEL_HIGH_PRIORITY_2)
}
// ...batches this.state.splotches by size, calling sendBatch per batch
}
// receive
case 'splotch_sync':
if (json.data.first) state.splotches = []
json.data.splotches.forEach(s => state.splotches.push({ x: s.x, y: s.y, colorIndex: s.c, angle: s.a, startTimeMs: s.t }))
breakGodot (C#)
// OnSystemMessageReceived, "CONNECT" case
var newNetId = RelayService.GetNetIdForCxId(cxId);
SendSplotchSync(1ul << newNetId);
void SendSplotchSync(ulong toMask) {
// chunk, then:
_brainCloudWrapper.RelayService.SendToPlayers(packet, toMask, true, true, BrainCloudRelay.CHANNEL_HIGH_PRIORITY_2);
}Godot (GDScript)
func on_relay_system(msg: Dictionary) -> void:
if msg.get("op") == "CONNECT" and is_host:
_send_splotch_sync(msg.get("netId"))
func _send_splotch_sync(net_id: int) -> void:
# chunks _splotch_records into <=900-byte packets (relay MAX_PACKETSIZE is 1024)
AppState.bc.relay_service.send(packet, net_id, true, true, BrainCloudRelay.CHANNEL_HIGH_PRIORITY_2)
func _on_splotch_sync(data: Dictionary) -> void:
if data.first: # clear canvas
...
# replay each entry preserving original `t` so fade timing matches other clientsGotchas worth knowing up front
- MEMBER_JOIN (lobby) fires too early to resync from. It signals that someone joined the lobby, not that they're relay-connected yet. Wait for the relay's own CONNECT system event.
- Resync to a mask, not to everyone. Broadcasting the full canvas to already-caught-up peers on every join wastes bandwidth and risks visibly "replaying" splotches those peers already have. Target the single joining peer's net-id bit instead.
- Preserve original timestamps on replay, don't stamp them with "now." Synced state with any time-based behavior, like fade-out or expiry, needs each replayed entry to carry its original timestamp, or a late joiner sees a full-strength copy that outlives the original instead of matching everyone else's fade timing.