Unreal Engine SDK reference
The Braket Unreal Engine plugin implements the game-client protocol (The game-client result protocol (dual attestation)) for you: 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 never reads your game state; you name the winner and hand it your own game-defined result fields as JSON. It ships a self-contained, NIST-vector-verified SHA-256 and a canonical JSON serializer so it adds no extra module dependencies, speaks braket.result.v3 transparently for both 1v1 and team tournaments, and exposes everything to Blueprint. This chapter is the complete API reference plus a step-by-step wire-up. Every signature, field, and default is quoted from the plugin source under sdk/unreal/Braket/.
The plugin is MIT-licensed.
Install
- Copy the
Braket/folder into your project'sPlugins/directory. - Regenerate project files and rebuild (this adds the
HTTP+ Steamworks dependencies). - Enable Braket in Edit, Plugins if it is not already enabled.
- Open Project Settings, Plugins, Braket, and set Base URL to your arena origin, for example
https://yourgame.braket.gg. For local testing against a Braket in mock mode, set a Mock Steam Id instead.
The plugin requires the Steam Online Subsystem (OnlineSubsystemSteam): it uses GetAuthTicketForWebApi("braket") for authentication.
Build.cs dependencies
The module (Braket.Build.cs) declares these public dependencies:
Core, CoreUObject, Engine, HTTP, Json, JsonUtilities,
DeveloperSettings, OnlineSubsystem, OnlineSubsystemUtils, CoreOnline
It also links the Steamworks SDK directly for GetAuthTicketForWebApi, because no OnlineSubsystem wrapper exposes the Web-API ticket. This is guarded by the BRAKET_WITH_STEAMWORKS define: when the module compiles against the engine, it adds the Steamworks third-party dependency and sets BRAKET_WITH_STEAMWORKS=1; otherwise it sets it to 0 and the Steam paths compile out (mock mode still works).
Configuration: UBraketSettings
UBraketSettings is a UDeveloperSettings (Config = Game) surfaced at Project Settings, Plugins, Braket. Override it in Config/DefaultGame.ini under [/Script/Braket.BraketSettings]. Every field:
| Field | Type | Default | Meaning |
|---|---|---|---|
bEnabled |
bool | true |
Master switch. When false, FetchLiveMatch and SubmitResult are no-ops and auto-discovery does not start |
BaseUrl |
FString | "https://yourgame.braket.gg" |
Your arena origin, no trailing slash (a trailing slash is stripped) |
MaxSubmitAttempts |
int32 | 3 |
Result-submission retry attempts (clamped 1 to 5), used with exponential backoff |
bAutoDiscover |
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 |
FString | "" |
Dev only: a simulated SteamID64. When set, the plugin uses MOCK:<id> tickets against a Braket in mock mode. Leave empty in production |
The subsystem: UBraketSubsystem
UBraketSubsystem is a UGameInstanceSubsystem. Access it anywhere:
UBraketSubsystem* Braket = GetGameInstance()->GetSubsystem<UBraketSubsystem>();
On Initialize it creates the Steam ticket bridge (when Steam is running) and, if bEnabled && bAutoDiscover, starts the auto-discovery ticker. On Deinitialize it removes the ticker and tears down the bridge.
Public functions
Every public member function, with its exact signature:
/** Ask Braket whether this player has a LIVE tournament match. Async, fires OnLiveMatchUpdated. */
UFUNCTION(BlueprintCallable, Category = "Braket")
void FetchLiveMatch();
FetchLiveMatch requests a Steam Web API ticket, then GETs /api/v1/me/live-match. On 204 it broadcasts an empty (invalid) FBraketLiveMatch; on 200 it fills LiveMatch from the JSON and broadcasts it; on other codes it logs and does nothing. It is a no-op when bEnabled is false or no ticket can be obtained.
/** Submit this client's view of the finished match. Safe on both host and guest. Idempotent per match. */
UFUNCTION(BlueprintCallable, Category = "Braket")
void SubmitResult(const FBraketMatchResult& Result);
SubmitResult is a no-op when bEnabled is false, or when WinnerSteamId is empty (treated as a draw / no result). Otherwise it re-fetches /api/v1/me/live-match to get the current nonce and canonical slot order, verifies the local player and the winner belong to the match, builds the result bytes, marks the match as submitted (idempotency guard: it will not submit the same match id twice in a session), and POSTs to /api/v1/matches/{matchId}/result.
UFUNCTION(BlueprintPure, Category = "Braket")
const FBraketLiveMatch& GetLiveMatch() const;
GetLiveMatch returns the last discovered live match (invalid until one is found).
/**
* Build the exact result bytes to hash + submit (braket.result.v3): the reserved
* keys (matchId, sessionNonce, winner) merged with your game-defined fields from
* GameFieldsJson, serialized CANONICALLY (recursively key-sorted, compact).
*/
static FString CanonicalizeResult(int32 MatchId, const FString& Nonce, const FString& Winner,
const FString& GameFieldsJson);
/** SHA-256 hex (lowercase) of a UTF-8 string — self-contained, no extra deps. */
static FString Sha256HexUtf8(const FString& Input);
CanonicalizeResult produces the exact bytes both clients hash and submit. It parses your GameFieldsJson object (if any), copies its fields (ignoring any matchId/sessionNonce/winner you put there), then forces the three reserved keys and serializes the whole object with recursively key-sorted, compact JSON. That canonical order is why two clients that observed the same result produce byte-identical bytes no matter what order they built their fields in. Numbers print as integers when integral. Sha256HexUtf8 returns the lowercase-hex SHA-256 of a UTF-8 string. Both are static and exist mainly so you can unit-test that your result bytes and hash match the server's.
Delegates
The two core dynamic multicast delegates (the recording delegates are covered in Match recordings and replays below), declared as:
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnBraketLiveMatch, const FBraketLiveMatch&, LiveMatch);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnBraketResultSubmitted, bool, bAccepted, const FString&, Status);
exposed as BlueprintAssignable properties:
| Delegate property | Type | Fires when | Parameters |
|---|---|---|---|
OnLiveMatchUpdated |
FOnBraketLiveMatch |
Discovery completes (manual or auto) | const FBraketLiveMatch& LiveMatch (invalid if none) |
OnResultSubmitted |
FOnBraketResultSubmitted |
A submission finishes (success or terminal failure) | bool bAccepted, const FString& Status |
On success, Status is the server's reconciliation outcome string ("confirmed", "pending", "disputed", or "duplicate"; falls back to "ok"). On terminal failure, bAccepted is false and Status is http_<code> (for example http_422).
FBraketLiveMatch
The live match discovered from GET /api/v1/me/live-match. p1/p2 are Braket slot order (the canonical order used in the result), not your engine's player indices. Every field:
| Field | Type | Meaning |
|---|---|---|
MatchId |
int32 | Braket match id |
GameSeed |
int64 | 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 |
int32 | The local player's slot, 1 or 2 (their side's slot in team mode) |
SessionNonce |
FString | Per-match nonce; the reserved sessionNonce in your result |
P1SteamId |
FString | Slot-1 SteamID64 |
P2SteamId |
FString | Slot-2 SteamID64 |
OpponentSteamId |
FString | The other side's SteamID64 |
TournamentName |
FString | Tournament display name |
TournamentSlug |
FString | Tournament slug |
WindowClosesAt |
FString | ISO-8601 window close |
bAllowSpectators |
bool | Tournament allows casting/spectating |
CastHandMode |
FString | Recording-release policy for hidden information (see below): "hidden", "delayed", or "shown". Empty/absent → treat as "hidden" |
CastDelaySeconds |
int32 | For "delayed": seconds after capture before a full-scope (both-hands) recording or segment is released |
Casters |
TArray<FBraketCaster> | Every active caster on the match (see below); a match can have several |
CasterSteamId |
FString | First caster's SteamID64 (mirrors Casters[0]), for convenience |
StreamUrl |
FString | First caster's stream URL (mirrors Casters[0]), for convenience |
P1Roster |
TArray<FString> | Registered SteamID64s of side 1 (single element for 1v1) |
P2Roster |
TArray<FString> | Registered SteamID64s of side 2 (single element for 1v1) |
TeamMinPlayers |
int32 | Minimum players per side (default 1) |
TeamMaxPlayers |
int32 | Maximum players per side (default 1) |
ResultProtocol |
FString | Always "braket.result.v3" |
bRefereeEnabled |
bool | This arena runs the M0 AI referee (from the discovery response's refereeM0 object) |
bRefereeConsentComplete |
bool | Both players granted training consent on the website; traces and captures are accepted only then |
FBraketCaster (all BlueprintReadOnly): SteamId (FString), StreamUrl (FString), Source (FString — community, invited, or arena). Use Casters to show every caster covering the match and list who is streaming where; CasterSteamId/StreamUrl remain for single-caster integrations.
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.
Helper methods:
bool IsValid() constreturns true whenMatchId > 0 && !SessionNonce.IsEmpty().bool IsTeamMatch() constreturns true whenTeamMaxPlayers > 1(used only to check the local player and winner against the rosters instead of the two slot IDs).
All fields are BlueprintReadOnly.
FBraketMatchResult
What your game reports at match end. Your game owns the result shape: you name the winner and provide your own fields as JSON. Every field:
| Field | Type | Default | Meaning |
|---|---|---|---|
WinnerSteamId |
FString | "" |
SteamID64 of a player on the winning side (the reserved winner key). Must be one of the two sides' players. Empty submits nothing (a draw / abandoned session) |
ResultJson |
FString | "" |
Your game-defined result as a JSON object string, e.g. {"score":[2,1],"turns":17,"seed":"0451d2be"}. Braket stores it verbatim and never interprets it. Both clients must produce the same fields. Any matchId/sessionNonce/winner you put here are ignored (the SDK sets the reserved keys). Leave empty to attest only the reserved keys |
bIsHost |
bool | false |
True if this client is the listen-server host (metadata only; becomes clientRole) |
OpponentSteamId |
FString | "" |
Anti-mixup guard (1v1): the SteamID64 you actually played this session. If set and it does not match the tournament opponent, the SDK refuses to submit |
All fields are BlueprintReadWrite.
Building ResultJson deterministically
The single rule: both clients must produce the same fields. The SDK canonicalizes key order for you, so you do not have to worry about the order in which you add fields — but you do have to make sure the values are equal on both clients and come from replicated, host-authoritative state. Practical guidance:
- Only include facts both clients observe identically (winner, scores, turn count, the replicated seed). These live in your replicated GameState.
- Do not put wall-clock timestamps or any per-client-local value inside
ResultJson; the SDK already sendsendedAtas unhashed metadata. - Use plain integers for counts/scores (they serialize canonically); avoid floats.
- The simplest builder is a
Printfwith your fields, as shown in the wire-up below; anything that yields the same JSON object on both clients works.
The anti-mixup guard
In 1v1, if you set OpponentSteamId to whoever you actually faced this session and it does not equal the live match's OpponentSteamId, the SDK logs a warning and does not submit. This guarantees a casual game, or a game against the wrong player, can never post a tournament result. Setting it is optional but recommended. For team matches the guard is skipped; roster membership is the equivalent check (your local SteamID64 must be on one of the two rosters, or the SDK skips the submission).
How the SDK builds a submission
Inside SubmitResult (via BuildSubmission), after a fresh discovery the SDK:
- Resolves the local SteamID64 and confirms it is a player of the match — in 1v1 it must equal
P1SteamIdorP2SteamId; in a team match it must be onP1RosterorP2Roster. If not, it skips. - Applies the 1v1 anti-mixup guard if you set
OpponentSteamId. - Confirms
WinnerSteamIdis a player of one of the two sides (the server enforces this too); if not, it skips. - Calls
CanonicalizeResult(MatchId, SessionNonce, WinnerSteamId, ResultJson)to build the exact result bytes, and hashes them withSha256HexUtf8. - POSTs
{ "protocol": "braket.result.v3", "result": <canonical string>, "resultHash": <hash>, "clientRole": "host"|"guest", "endedAt": <UTC now> }.
result is sent as a JSON string value (its quotes escaped); the server re-hashes those exact bytes, compares to resultHash, then reads the reserved keys. There is no separate winnerSteamId, p1Score, winnerSide, or roster field on the wire any more — all of that is now inside your result object where you want it.
Auto-discovery behavior
When bEnabled && bAutoDiscover, Initialize registers a core ticker that calls FetchLiveMatch() every AutoDiscoverIntervalSeconds. The ticker keeps firing while !LiveMatch.IsValid() and stops once a valid match is found. So you only need to bind OnLiveMatchUpdated and show your banner; no manual polling is needed. If you prefer to poll yourself, set bAutoDiscover = false and call FetchLiveMatch() when you choose.
Retry and backoff
SubmitResult retries transient failures using MaxSubmitAttempts:
- The response is treated as success on HTTP
200;OnResultSubmitted.Broadcast(true, Status)fires with the server's outcome. - A retry happens only when the HTTP code is
0(network failure) or>= 500, and attempts remain.4xxresponses are definitive (bad hash, not a participant, and so on): no retry, because the manual fallback exists. - The backoff delay before a retry is
2^(MaxSubmitAttempts - AttemptsLeft + 1)seconds, so with the default 3 attempts the delays grow exponentially. - When attempts are exhausted, the match id is removed from the submitted set (so a later manual retry is possible) and
OnResultSubmitted.Broadcast(false, "http_<code>")fires.
The SDK also guards idempotency within a session: it tracks submitted match ids and will not re-submit the same match id, and it refreshes the live match (nonce, slot order, rosters) immediately before every submission so the result is built against current server state.
Blueprint usage
Everything you need is Blueprint-exposed: the subsystem is a UGameInstanceSubsystem, FetchLiveMatch and SubmitResult are BlueprintCallable, GetLiveMatch is BlueprintPure, both delegates are BlueprintAssignable, and both structs are BlueprintType with BlueprintReadOnly/BlueprintReadWrite fields. In Blueprint: get the Braket subsystem from the Game Instance, bind an event to OnLiveMatchUpdated and OnResultSubmitted, call Fetch Live Match, and at match end make a Braket Match Result struct (set Winner Steam Id and a Result Json string) and call Submit Result.
Step-by-step wire-up
C++
Show a banner when a match is found. On your main menu:
UBraketSubsystem* Braket = GetGameInstance()->GetSubsystem<UBraketSubsystem>(); Braket->OnLiveMatchUpdated.AddDynamic(this, &UMyMenu::OnBraketMatch); Braket->FetchLiveMatch(); // or rely on auto-discovery void UMyMenu::OnBraketMatch(const FBraketLiveMatch& M) { if (M.IsValid()) ShowBanner(M.TournamentName, M.OpponentSteamId); }Add a replicated deterministic seed to your match GameState. The host generates it at match start, replicates it, and drives all shuffle/RNG from it. You include it in
ResultJson.Submit on both clients at match end. Fire this from a signal that runs on both host and guest (a RepNotify on your winner/phase, or an
OnGameOvermulticast), gated to ranked/tournament sessions:FBraketMatchResult R; R.WinnerSteamId = WinnerSteamId64; // any player on the winning side R.ResultJson = FString::Printf( TEXT("{\"score\":[%d,%d],\"turns\":%d,\"seed\":\"%08x\"}"), SlotOneScore, SlotTwoScore, NumTurns, MatchSeed); // your own fields R.bIsHost = HasAuthority(); R.OpponentSteamId = ActualOpponentSteamId64; // anti-mixup guard (recommended) Braket->SubmitResult(R); Braket->OnResultSubmitted.AddDynamic(this, &UMyHUD::OnBraketSubmitted); // Status is "confirmed" | "pending" | "disputed" | "duplicate" (or http_<code> on failure)For a team match nothing changes: set
WinnerSteamIdto anyone on the winning side and Braket maps it to that side via the rosters. Put any per-side detail you want insideResultJson.
Blueprint
- From Event Construct on your menu widget, Get Game Instance Subsystem (Braket Subsystem), bind On Live Match Updated, and call Fetch Live Match (optional if auto-discovery is on).
- In the bound event, branch on Is Valid of the live match and show your banner.
- On your match-end event (running on both clients), Make Braket Match Result, set Winner Steam Id and a Result Json string built from your replicated state, and call Submit Result.
- Bind On Result Submitted to update the HUD from the returned status string.
Verifying your bytes
CanonicalizeResult and Sha256HexUtf8 are 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 Sha256HexUtf8 of that string is:
5ab1be222c607be9c897be0d5cf400be99e7a7d29845aa78c4a0ab59ee39e2f8
If your two clients disagree on this hash, they are not producing the same ResultJson fields; fix the divergence in your game state until both print identical bytes.
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) that Braket uses to train its AI referee. A trace
never affects the outcome: a failed or disagreeing trace only excludes that
match from training data.
Tracing is fully inert unless all three conditions hold:
bEnableRefereeis on (Project Settings → Braket, orConfig/DefaultGame.ini).- The arena runs the referee (
FBraketLiveMatch::bRefereeEnabled, from discovery). - Both players granted training consent on braket.gg (
bRefereeConsentComplete). Consent is handled entirely on the website — the game never prompts for it.
IsRefereeTraceActive() folds all three together. When it is false, every
recording call below is a cheap no-op, so you can wire them unconditionally. A
console variable braket.Referee overrides the setting at runtime (-1 use
setting, 0 force off, 1 force on).
Recording API
Label actors by Braket slot (1 = p1, 2 = p2), never "me/opponent" — each
client submits its own trace and the server compares them:
void BeginGameplayTrace(); // once at match start → match_started (t=0)
void TraceRoundStarted(int32 Round);
void TraceRoundEnded(int32 Round, int32 WinnerSlot, int32 P1Score, int32 P2Score); // WinnerSlot 0=none
void TraceTurnStarted(int32 ActorSlot, int32 Turn); // ActorSlot 1|2
void TracePublicAction(int32 ActorSlot, const FString& ActionId, const FString& Target); // Target "p1"|"p2"|"board"|""
void TracePublicScoreChanged(int32 ActorSlot, int32 P1, int32 P2); // ActorSlot 0=none
void EndGameplayTraceAndSubmit(int32 WinnerSlot, const FString& Reason); // → match_ended + submit
Reason is "normal", "forfeit", "disconnect", or "timeout". The recorder
stamps seq and a monotonic tMs for you, builds the braket.gameplay.v1
envelope, serializes the trace canonically and SHA-256-hashes it with the
same serializer as results (so both clients produce identical bytes), and POSTs
it to /api/v1/matches/{matchId}/gameplay-trace.
Wiring it symmetrically
Both clients must record the same public events, so drive them from
replicated/multicast signals, not from client-local prediction. In Herd the wiring
is: turn_started ← GS->OnActivePlayerChanged, public_score_changed ←
OnCreatureCountsChanged, match_ended ← OnGameOver (all replicated GameState
delegates), and public_action ← a NetMulticast (Multicast_BraketCardPlayed)
fired from the card-play ability on the server, so the host and the guest log
identical card plays.
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 plugin source but were written blind against the server API and still need an engine compile 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.
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void SubmitRecording(const FString& ReplayJson, bool bCasual); // → OnRecordingSubmitted
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void FetchMyRecordings(); // → OnRecordingsListed
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void FetchRecording(int32 RecordingId, const FString& LinkToken); // → OnRecordingFetched
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void ResubmitPendingRecordings(); // retry the local spool
SubmitRecordingwithbCasual = falseposts the recording against the currentLiveMatch(arena-visible; full scope release-gated by the cast delay).bCasual = trueposts a private personal recording, subject to the player's storage quota. No-op if the plugin 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 toSaved/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.
Delegates: OnRecordingSubmitted(bool bOk, int32 RecordingId, const FString& Status), OnRecordingsListed(const TArray<FBraketRecordingInfo>& Recordings), OnRecordingFetched(int32 RecordingId, const FString& DownloadUrl). FBraketRecordingInfo 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:
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void SubmitRecordingSegment(int32 RecordingId, int32 Seq, const FString& FragmentJson); // → OnSegmentSubmitted
UFUNCTION(BlueprintCallable, Category = "Braket|Recording")
void FetchRecordingSegments(int32 RecordingId, int32 AfterSeq, const FString& LinkToken); // → OnSegmentsListed
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; its delegate OnSegmentsListed(bool bLive, const TArray<FBraketSegmentInfo>& Segments) reports bLive = true while the recording is still being appended (keep polling) and false once it ended. FBraketSegmentInfo is { Seq, Url }.
Rollout
The recommended rollout is: run a shadow phase first, where the game submits results but manual reporting stays authoritative for one tournament, and watch the server's 422 hash_mismatch rate to catch any non-determinism; then switch to authoritative, where game-client agreement confirms matches and manual reporting is disabled for live matches (admin override always remains).
Other engines
On Unity, use the ready-made Unity SDK — its canonical serializer is byte-compatible with this one, so a Unity client and an Unreal client can play the same match. On any other engine (Godot, custom), the protocol is plain HTTPS + JSON + SHA-256 + a Steam Web API ticket; the wire contract is The game-client result protocol (dual attestation).