braket.gg
EN

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

  1. Copy the Braket/ folder into your project's Plugins/ directory.
  2. Regenerate project files and rebuild (this adds the HTTP + Steamworks dependencies).
  3. Enable Braket in Edit, Plugins if it is not already enabled.
  4. 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:

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:

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:

  1. Resolves the local SteamID64 and confirms it is a player of the match — in 1v1 it must equal P1SteamId or P2SteamId; in a team match it must be on P1Roster or P2Roster. If not, it skips.
  2. Applies the 1v1 anti-mixup guard if you set OpponentSteamId.
  3. Confirms WinnerSteamId is a player of one of the two sides (the server enforces this too); if not, it skips.
  4. Calls CanonicalizeResult(MatchId, SessionNonce, WinnerSteamId, ResultJson) to build the exact result bytes, and hashes them with Sha256HexUtf8.
  5. 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 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++

  1. 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);
    }
    
  2. 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.

  3. 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 OnGameOver multicast), 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 WinnerSteamId to anyone on the winning side and Braket maps it to that side via the rosters. Put any per-side detail you want inside ResultJson.

Blueprint

  1. 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).
  2. In the bound event, branch on Is Valid of the live match and show your banner.
  3. 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.
  4. 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:

  1. bEnableReferee is on (Project Settings → Braket, or Config/DefaultGame.ini).
  2. The arena runs the referee (FBraketLiveMatch::bRefereeEnabled, from discovery).
  3. 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_startedGS->OnActivePlayerChanged, public_score_changedOnCreatureCountsChanged, match_endedOnGameOver (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

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