Unity SDK reference
The Braket Unity SDK is the Unity counterpart of the Unreal Engine SDK: it discovers the local player's live tournament match, optionally auto-polls for one, and submits verified results on match end with retries and backoff. It speaks the same wire protocol (The game-client result protocol (dual attestation)), braket.result.v3, and its canonical serializer is byte-for-byte compatible with the Unreal SDK and the server, so a Unity client and an Unreal client in the same match produce the identical hash. This chapter is the complete API reference plus a step-by-step wire-up; every field and default is quoted from the package source under sdk/unity/Braket/.
The package is MIT-licensed.
Install
- Copy the
Braket/folder into your project (for exampleAssets/Braket/), or add it as a UPM package. - Add the BraketClient component to a persistent GameObject, one that survives scene loads (your bootstrap or "game instance" object).
Requirements:
- HTTP: the SDK uses Unity's built-in
UnityWebRequest, so it works on every platform Unity ships, including WebGL. - Steam auth: a single-use Steam Web API ticket via
SteamUser.GetAuthTicketForWebApi("braket"). Install Steamworks.NET and add the scripting defineBRAKET_WITH_STEAMWORKS(Project Settings → Player → Scripting Define Symbols) to enable real tickets. Without it, only mock mode works (set Mock Steam Id).- Steamworks.NET's common install compiles into the default
Assembly-CSharp. If you keep theBraket.asmdef, either reference a Steamworks.NET assembly definition from it, or deleteBraket.asmdefso Braket compiles alongside Steamworks.NET. This is the usual Unity assembly-definition caveat, not specific to Braket.
- Steamworks.NET's common install compiles into the default
Configuration: the BraketClient component
BraketClient is a MonoBehaviour; configure it in the inspector. Every field:
| Field | Type | Default | Meaning |
|---|---|---|---|
Enabled |
bool | true |
Master switch. When false, FetchLiveMatch and SubmitResult are no-ops and auto-discovery does not start |
BaseUrl |
string | "https://yourgame.braket.gg" |
Your arena origin, no trailing slash (a trailing slash is stripped) |
MaxSubmitAttempts |
int | 3 |
Result-submission retry attempts (clamped 1 to 5), used with exponential backoff |
AutoDiscover |
bool | true |
Poll for a live match automatically; fires OnLiveMatchUpdated; stops once a match is found |
AutoDiscoverIntervalSeconds |
float | 60 |
Seconds between auto-discovery polls (clamped 15 to 600) |
MockSteamId |
string | "" |
Dev only: a simulated SteamID64. When set, the SDK uses MOCK:<id> tickets against a Braket in mock mode. Leave empty in production |
EnableReferee |
bool | false |
Stream public gameplay traces (braket.gameplay.v1) for AI-referee training. Inert unless the arena enabled the referee and both players consented on braket.gg |
RefereeTraceSchema |
string | "herd.gameplay.v1" |
Trace schema id; must match ^[a-z0-9][a-z0-9.-]{2,79}\.v[1-9][0-9]*$ |
RefereeRuleset |
string | "herd-standard" |
Ruleset label stamped into the trace envelope |
The client: BraketClient
Access the component and drive it from your own code:
var braket = FindObjectOfType<Braket.BraketClient>();
Public methods and events
public void FetchLiveMatch();
public void SubmitResult(BraketMatchResult result);
public BraketLiveMatch LiveMatch { get; } // last discovered match
public event Action<BraketLiveMatch> OnLiveMatchUpdated; // discovery completed
public event Action<bool, string> OnResultSubmitted; // (accepted, status)
FetchLiveMatch()requests a Steam Web API ticket, then GETs/api/v1/me/live-match. On204it invokesOnLiveMatchUpdatedwith an invalidBraketLiveMatch; on200it fillsLiveMatchand invokes the event; other codes are logged. No-op whenEnabledis false or no ticket can be obtained.SubmitResult(result)is a no-op whenEnabledis false orWinnerSteamIdis empty (a draw). Otherwise it re-fetches/api/v1/me/live-matchfor the current nonce and slot order, verifies the local player and the winner belong to the match, builds the result bytes, marks the match as submitted (it will not submit the same match id twice in a session), and POSTs to/api/v1/matches/{matchId}/result.OnResultSubmitted(accepted, status): on success,statusis the server's reconciliation outcome ("confirmed","pending","disputed", or"duplicate"; falls back to"ok"). On terminal failure,acceptedisfalseandstatusishttp_<code>.
Auto-discovery
When Enabled && AutoDiscover, the component polls FetchLiveMatch() every AutoDiscoverIntervalSeconds from Update() while no valid match is known, and stops once one is found. Bind OnLiveMatchUpdated and show your banner; no manual polling needed. Set AutoDiscover = false to poll yourself.
BraketLiveMatch
The live match discovered from GET /api/v1/me/live-match. P1/P2 are Braket slot order (the canonical order), not your engine's player indices. Fields:
| Field | Type | Meaning |
|---|---|---|
MatchId |
int | Braket match id |
GameSeed |
long | Braket-issued fairness seed, minted server-side when the match went live. Drive your shuffle/RNG from it so a player-host cannot pick a favorable deal. 0 means no seed was issued (casual session): use a host-generated seed |
Slot |
int | The local player's slot, 1 or 2 (their side's slot in team mode) |
SessionNonce |
string | Per-match nonce; the reserved sessionNonce in your result |
P1SteamId / P2SteamId |
string | Slot-1 / slot-2 SteamID64 |
OpponentSteamId |
string | The other side's SteamID64 |
TournamentName / TournamentSlug |
string | The tournament this match belongs to |
WindowClosesAt |
string | ISO-8601 window close |
AllowSpectators |
bool | Tournament allows casting/spectating |
CastHandMode |
string | Recording-release policy for hidden information (see below): "hidden", "delayed", or "shown". Empty/absent → treat as "hidden" |
CastDelaySeconds |
int | For "delayed": seconds after capture before a full-scope (both-hands) recording or segment is released |
RefereeEnabled |
bool | The arena runs the M0 referee |
RefereeConsentComplete |
bool | Both players granted training consent on braket.gg |
Casters |
List<BraketCaster> | Every active caster on the match (a match can have several) |
CasterSteamId / StreamUrl |
string | First caster (convenience mirror of Casters[0]) |
P1Roster / P2Roster |
List<string> | Registered SteamID64s per side (single element for 1v1) |
TeamMinPlayers / TeamMaxPlayers |
int | Players per side (>1 means a team tournament) |
ResultProtocol |
string | Always "braket.result.v3" |
Helpers: IsValid (MatchId > 0 && SessionNonce non-empty) and IsTeamMatch (TeamMaxPlayers > 1). BraketCaster has SteamId, StreamUrl, and Source (community / invited / arena).
Hidden-information policy (CastHandMode). A player's hand is hidden information, so exposing it is a stream-sniping vector. Watching a Braket match means replaying its recording — there is no live in-game spectator feed — and the arena sets, per tournament, when the full-scope (both-hands) recording may be released: "hidden" (default) never releases hands, so only public-scope recordings exist; "delayed" releases full-scope recordings and near-live segments CastDelaySeconds after capture; "shown" releases full scope immediately (only sound for trusted, neutral-hosted broadcasts). Your game uses this policy to decide which scope to record and upload; the release gate itself is enforced server-side on fetch. Players themselves are unaffected; each still receives only their own hand during play.
BraketMatchResult
What your game reports at match end. Your game owns the result shape: name the winner and provide your own fields as JSON.
| Field | Type | Meaning |
|---|---|---|
WinnerSteamId |
string | SteamID64 of a player on the winning side (the reserved winner key). Empty submits nothing (a draw) |
ResultJson |
string | Your game-defined result as a JSON object string, e.g. {"score":[2,1],"turns":17,"seed":"0451d2be"}. Stored verbatim; both clients must produce the same fields. Reserved keys you include are ignored (the SDK sets them). Empty attests only the reserved keys |
IsHost |
bool | True if this client is the listen-server host (metadata only; becomes clientRole) |
OpponentSteamId |
string | Anti-mixup guard (1v1): the SteamID64 you actually played. If set and it doesn't match the tournament opponent, the SDK refuses to submit |
Building ResultJson deterministically
The single rule: both clients must produce the same fields. The SDK canonicalizes key order for you (BraketJson.CanonicalizeResult sorts keys recursively), so field insertion order doesn't matter — but the values must be equal on both clients and come from replicated, host-authoritative state. Only include facts both clients observe identically (winner, scores, turn count, the replicated seed); do not put wall-clock timestamps in ResultJson (the SDK sends endedAt as unhashed metadata). Team matches: set WinnerSteamId to anyone on the winning side and Braket maps it via the rosters.
How the SDK builds a submission
After a fresh discovery, SubmitResult resolves the local SteamID64 and confirms it is a player of the match (in team mode, on a roster), applies the 1v1 anti-mixup guard, confirms WinnerSteamId is a player of one side, then calls BraketJson.CanonicalizeResult(MatchId, SessionNonce, WinnerSteamId, ResultJson) and hashes the bytes with BraketJson.Sha256Hex. It POSTs { "protocol": "braket.result.v3", "result": <canonical string>, "resultHash": <hash>, "clientRole": "host"|"guest", "endedAt": <UTC now> }. result is sent as a JSON string value; the server re-hashes those exact bytes, compares to resultHash, then reads the reserved keys.
Retries follow MaxSubmitAttempts: only HTTP 0 (network failure) or >= 500 retry, with 2^(MaxSubmitAttempts - attemptsLeft + 1)-second backoff; 4xx is definitive (the manual fallback exists). When attempts are exhausted the match id is released for a later manual retry.
Verifying your bytes
BraketJson.CanonicalizeResult and BraketJson.Sha256Hex are public static, so you can unit-test the exact bytes and hash your game will send. For match 842, nonce c3f1a4e89b02d715, winner 76561197977425772, and ResultJson = {"score":[2,1],"turns":17,"seed":"0451d2be"}, CanonicalizeResult returns (keys sorted, compact):
{"matchId":842,"score":[2,1],"seed":"0451d2be","sessionNonce":"c3f1a4e89b02d715","turns":17,"winner":"76561197977425772"}
and Sha256Hex of that string is:
5ab1be222c607be9c897be0d5cf400be99e7a7d29845aa78c4a0ab59ee39e2f8
That is the same value the Unreal SDK and the server produce, which is why a Unity client and an Unreal client can play the same match: they hash identically. If your two clients disagree on this hash, they are not producing the same ResultJson fields; fix the divergence until both print identical bytes.
Step-by-step wire-up
using Braket;
public class TournamentHook : MonoBehaviour
{
BraketClient braket;
void Start()
{
braket = FindObjectOfType<BraketClient>();
braket.OnLiveMatchUpdated += m => { if (m.IsValid) ShowBanner(m.TournamentName, m.OpponentSteamId); };
braket.OnResultSubmitted += (ok, status) => Debug.Log($"Braket: {status}");
braket.FetchLiveMatch(); // or rely on Auto Discover
}
// Call on BOTH clients from a match-end signal (a networked RPC / RepNotify equivalent),
// gated to ranked/tournament sessions.
public void OnMatchOver(string winnerSteamId64, int myScore, int oppScore, int turns, uint seed, bool isHost)
{
var r = new BraketMatchResult {
WinnerSteamId = winnerSteamId64,
ResultJson = $"{{\"score\":[{myScore},{oppScore}],\"turns\":{turns},\"seed\":\"{seed:x8}\"}}",
IsHost = isHost,
};
braket.SubmitResult(r);
}
}
Optional: M0 AI-referee gameplay traces
Independently of the result, the SDK can stream a public gameplay trace
(protocol braket.gameplay.v1) — a per-event log of public match state (turns,
played cards, visible score) used to train Braket's AI referee. A trace never
affects the outcome: a failed or disagreeing trace only excludes that match from
training data.
It is inert unless all three hold: EnableReferee is on (on the BraketClient
component), the arena runs the referee (BraketLiveMatch.RefereeEnabled), and
both players granted training consent on braket.gg (RefereeConsentComplete).
Consent is handled on the website — the game never prompts. IsRefereeTraceActive
folds all three together; when it is false, every call below is a cheap no-op, so
you can wire them unconditionally.
Label actors by Braket slot (1 = p1, 2 = p2), never "me/opponent" — each
client submits its own trace and the server compares them:
public void BeginGameplayTrace(); // once at match start → match_started (t=0)
public void TraceRoundStarted(int round);
public void TraceRoundEnded(int round, int winnerSlot, int p1Score, int p2Score); // winnerSlot 0=none
public void TraceTurnStarted(int actorSlot, int turn); // actorSlot 1|2
public void TracePublicAction(int actorSlot, string actionId, string target); // target "p1"|"p2"|"board"|""
public void TracePublicScoreChanged(int actorSlot, int p1, int p2); // actorSlot 0=none
public void EndGameplayTraceAndSubmit(int winnerSlot, string reason); // → match_ended + submit
reason is "normal", "forfeit", "disconnect", or "timeout". The recorder
stamps seq/tMs, builds the braket.gameplay.v1 envelope, serializes the trace
canonically and SHA-256-hashes it with the same BraketJson serializer as results
(so a Unity client and an Unreal client produce identical bytes), and POSTs it to
/api/v1/matches/{matchId}/gameplay-trace. Drive the calls from replicated /
authoritative signals so both clients record the same public events.
Convergence note: when the arena runs the referee and both players consented, finalizing a match recording (below) derives the braket.gameplay.v1 trace from it server-side. If your game uploads match recordings, a separate gameplay-trace submission is optional — the trace API remains available for games that record no replays.
Match recordings and replays
⚠️ Draft, pending engine verification. The recording and segment methods below are implemented in the SDK source but were written blind against the server API and still need an in-engine verification pass. The server endpoints they call are shipping (see the protocol chapter).
The SDK uploads herd.replay.v1 recordings produced by your replay recorder: init → PUT gzip to the presigned target → finalize. Scope, game build, and duration are read from the file's JSON header; the bytes never transit Braket's app server.
public void SubmitRecording(string replayJson, bool casual); // → OnRecordingSubmitted
public void FetchMyRecordings(); // → OnRecordingsListed
public void FetchRecording(int recordingId, string linkToken); // → OnRecordingFetched
public void ResubmitPendingRecordings(); // retry the local spool
public event Action<bool, int, string> OnRecordingSubmitted; // (ok, recordingId, status)
public event Action<List<BraketRecordingInfo>> OnRecordingsListed;
public event Action<int, string> OnRecordingFetched; // (recordingId, downloadUrl)
SubmitRecordingwithcasual = falseposts the recording against the currentLiveMatch(arena-visible; full scope release-gated by the cast delay).casual = trueposts a private personal recording, subject to the player's storage quota. No-op if the SDK is disabled orreplayJsonis empty.FetchMyRecordingslists the local player's recordings;FetchRecordingresolves a short-lived download URL (passlinkTokenonly for link-shared recordings) — the playback layer downloads and gunzips it.- Local spool: every
SubmitRecordingis persisted topersistentDataPath/BraketRecordings/pending/before upload; a confirmed finalize moves it to.../uploaded/(kept — they double as local replays).ResubmitPendingRecordingsretries everything still pending and is auto-invoked about 20 seconds after startup, so Braket being unreachable at match end never loses a recording.
BraketRecordingInfo carries Id, MatchId (0 = casual), Scope ("public"/"full"), GameBuild, DurationMs, Visibility ("private"/"arena"/"link"), and CreatedAt.
Near-live segments
A live recording can additionally be streamed as numbered herd.replay.v1 fragments (header carries segmentSeq and baseTMs), so watchers can replay the match while it is still running:
public void SubmitRecordingSegment(int recordingId, int seq, string fragmentJson); // → OnSegmentSubmitted
public void FetchRecordingSegments(int recordingId, int afterSeq, string linkToken); // → OnSegmentsListed
public event Action<bool, int, string> OnSegmentSubmitted; // (ok, seq, status)
public event Action<bool, List<BraketSegmentInfo>> OnSegmentsListed; // (live, segments)
SubmitRecordingSegment runs the per-segment init → PUT → finalize round-trip; the SDK only transports segments — your game times the flushes. FetchRecordingSegments lists ready segments with seq > afterSeq; OnSegmentsListed reports live = true while the recording is still being appended (keep polling) and false once it ended. BraketSegmentInfo is { Seq, Url }.
Other engines
The plugin/SDK are reference implementations, but the protocol is plain HTTPS + JSON + SHA-256 + a Steam Web API ticket, so it is implementable in any engine. The wire contract is The game-client result protocol (dual attestation).