braket.gg BETA
EN

The game-client result protocol (dual attestation)

This is the complete specification of how a game reports verified match results to Braket. It is engine-agnostic: plain HTTPS, JSON, SHA-256, and a single-use Steam Web API ticket. The Unreal plugin in Unreal Engine SDK reference implements exactly this; if you are on another engine you implement it directly against this chapter. Everything here is accurate to the shipping server (src/web/routes/api.ts, src/core/resultHash.ts, src/services/tournamentService.ts), because the server re-hashes and re-reads every submission and rejects anything that does not line up.

The core idea: the game owns the result shape

Earlier revisions of this protocol made Braket define the result fields (winner, scores, turn count, seed) and a canonical string layout. That was too rigid: every game has a different notion of a "result." Protocol braket.result.v3 inverts this. Your game defines the result as its own JSON object, serializes it deterministically, and hashes the exact bytes. Braket does not interpret the payload except for three reserved keys it needs to route the result, and it confirms a match when the required reporters submit matching hashes. Because your game state is deterministic and replicated, every client that played the match serializes byte-identical JSON and therefore produces the same hash — that shared hash is the agreement.

The whole protocol is one constant, three reserved keys, and a hash.

Why dual attestation

Games like these typically run as a listen server: one of the two players is the authoritative host. A submission "from the server" is therefore a submission from a player, who could forge it. Braket trusts no single submission. Both clients submit independently, and Braket confirms only when the two hashes agree. A host that flips the winner (or any other byte) at submission time produces a hash the honest guest will never produce, so the match escalates to a dispute instead of recording a wrong result. A modified client cannot fabricate an outcome the other client will not sign.

The attacks this closes: a non-participant forging a result (blocked by the Steam ticket + participant check), one participant forging a result (blocked by dual attestation), replay of an old result onto another match (blocked by matchId + the per-match sessionNonce inside the hashed bytes), a result for a match that never happened (blocked because the match must be live), and double submission (blocked by one-submission-per-side idempotency). The one residual risk, two colluding players agreeing on a false result, is identical to any report-based system and is a tournament-rules matter, not a protocol one.

Authentication

Both endpoints authenticate with a Steam Web API ticket, never cookies or sessions.

Tournaments carry an environment (demo — public players, the default — or the internal test channels rec/beta). When an arena accepts several AppIDs, live-match discovery only surfaces a tournament to the matching build: rec/beta tournaments to the primary (main-app) AppID, demo tournaments to the other accepted AppIDs. To the wrong build the response is simply 204 (no live match). Single-AppID arenas are unaffected.

Step 1: discovery, GET /api/v1/me/live-match

The game asks Braket whether the signed-in player currently has a live tournament match. No launch arguments are needed; call it on the main menu, on session start, or on a poll.

Before the match is live, GET /api/v1/me/upcoming-match returns the player's next scheduled match (204 if none): { matchId, slot, opponentSteamId, opponentName, tournament, windowOpensAt, windowClosesAt, windowOpen, selfReady, opponentReady }. The game may then ready the player up with POST /api/v1/matches/:id/ready (the in-game twin of the website ready button) → { status, live, opponentReady }; when both sides are ready the match goes live and live-match discovery takes over. Both endpoints apply the same environment/AppID gate as live-match. Ready errors: 403 not_participant, 404 match_not_found, 409 opponent_undecided | past_ready_phase | window_not_open | window_closed.

E-sport titles (unique, contested — one holder in the world): GET /api/v1/titles lists every title with holder, current reign, the 48 h clock (clockExpiresAt, null = vacant) and the full lineage. POST /api/v1/titles/:id/challenge challenges the holder; POST /api/v1/title-challenges/:id/respond { accept } answers — accepting creates a REGULAR organized title match (2 players, 48 h window) that the game discovers through upcoming/live-match like any other; POST /api/v1/titles/:id/volunteer queues for a vacant title, two volunteers trigger the coronation match. Same environment/AppID gate as tournaments; failures are 4xx { error } codes (title_already_held, not_holder, title_vacant_use_coronation…).

GET /api/v1/me/live-match
Authorization: Steam <ticket>

Responses:

{
  "matchId": 842,
  "gameSeed": 3405691582,
  "slot": 1,
  "sessionNonce": "c3f1a4e89b02d715",
  "p1SteamId": "76561197977425772",
  "p2SteamId": "76561198085460772",
  "opponentSteamId": "76561198085460772",
  "tournament": { "slug": "summer-cup", "name": "Summer Cup" },
  "windowClosesAt": "2026-07-12T20:00:00Z",
  "allowSpectators": true,
  "cast": { "handMode": "delayed", "delaySeconds": 180 },
  "casters": [],
  "casterSteamId": null,
  "streamUrl": null,
  "p1Roster": ["76561197977425772"],
  "p2Roster": ["76561198085460772"],
  "teamMinPlayers": 1,
  "teamMaxPlayers": 1,
  "resultProtocol": "braket.result.v3",
  "refereeM0": {
    "enabled": true,
    "consentComplete": false,
    "traceProtocol": "braket.gameplay.v1",
    "captureProtocol": "braket.capture.v1"
  }
}

Every field:

Field Type Meaning
matchId number The Braket match id; goes in your result's matchId and the submission URL
gameSeed number (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. Organized matches only — absent means a casual session (use a host-generated seed)
slot 1 or 2 The signed-in player's slot (or, for a roster member, their side's slot) in canonical order
sessionNonce string Per-match nonce minted when the match went live; goes verbatim in your result's sessionNonce
p1SteamId string SteamID64 of the slot-1 side (captain in team mode)
p2SteamId string SteamID64 of the slot-2 side (captain in team mode)
opponentSteamId string The other side's SteamID64 (for the anti-mixup check in 1v1)
tournament.slug / tournament.name string The tournament this match belongs to
windowClosesAt string (ISO-8601 UTC) When the match window closes
allowSpectators boolean Whether the tournament allows casting/spectating
cast.handMode string The tournament's recording-release policy for hidden information: hidden (hands never leave the players), delayed (full-scope recordings and segments unlock after a delay), or shown (full scope releases immediately). The client scopes its recordings accordingly
cast.delaySeconds number For delayed: how long after capture a full-scope (both-hands) recording or segment becomes fetchable. 0 otherwise
casters array Every active caster on this match. Each entry is { steamId, streamUrl, source } where source is community (openly claimed), invited (a player invited them), or arena (the arena's official caster). Empty when nobody is casting
casterSteamId string or null The first caster's SteamID64 (mirrors casters[0]), for older integrations
streamUrl string or null The first caster's stream URL (mirrors casters[0]), for older integrations
p1Roster string[] Registered SteamID64s of side 1 (single element for 1v1)
p2Roster string[] Registered SteamID64s of side 2 (single element for 1v1)
teamMinPlayers number Minimum players per side
teamMaxPlayers number Maximum players per side (>1 means a team tournament)
resultProtocol string Always "braket.result.v3"
refereeM0 object Present only when this arena runs the AI referee. enabled is true; consentComplete says whether both players have granted training consent (trace/capture calls are accepted only then); traceProtocol is "braket.gameplay.v1" and captureProtocol is "braket.capture.v1". Absent means: do not trace or capture

Both clients receive the same slot assignment and sessionNonce, which removes any ordering ambiguity. The client should confirm the Steam session opponent matches opponentSteamId; if it does not, this session is not the tournament match and the client must not submit. The rosters let a team game map its own players to Braket sides; the winning side is expressed simply by naming any one of its members in winner.

Step 2: build the result and hash it

Build your result object. It must contain the three reserved keys; the rest is up to you.

{
  "matchId": 842,
  "sessionNonce": "c3f1a4e89b02d715",
  "winner": "76561197977425772",
  "score": [2, 1],
  "turns": 17,
  "seed": "0451d2be"
}

Then serialize it deterministically and hash the exact UTF-8 bytes:

resultHash = SHA-256(resultBytes)      // lowercase hex

Determinism is your responsibility. The two clients must produce byte-identical result strings or their hashes will not match and the match will (correctly) go to a dispute. Practical rules:

The Unreal SDK does this for you with a fixed field order; on another engine, pick one serialization and use it identically on both clients.

Team battles

Nothing special. winner is the SteamID64 of any one player on the winning side; Braket maps it to that side via the tournament's team rosters, so your game never has to model "sides" or commit rosters itself. Put whatever per-side detail you like in your game-defined fields. The same braket.result.v3 shape covers 1v1 and NvM identically.

Step 3: submit, POST /api/v1/matches/:id/result

Submit on both clients (host and guest). It is idempotent per side.

POST /api/v1/matches/842/result
Authorization: Steam <ticket>
Content-Type: application/json

Body:

{
  "protocol": "braket.result.v3",
  "result": "{\"matchId\":842,\"sessionNonce\":\"c3f1a4e89b02d715\",\"winner\":\"76561197977425772\",\"score\":[2,1],\"turns\":17,\"seed\":\"0451d2be\"}",
  "resultHash": "b559ce48611abce4b20ceff0b7dbd70470c9258686b9dedd4c6c81d81119b88d",
  "clientRole": "host",
  "startedAt": "2026-07-12T18:03:11Z",
  "endedAt": "2026-07-12T18:24:47Z"
}

Note that result is a string — the exact serialized JSON you hashed, sent as a JSON string value (so its quotes are escaped). The server hashes those exact bytes and compares to resultHash, then parses the string to read the reserved keys.

Field reference and validation

Field Required Validation
protocol Yes Exactly "braket.result.v3"; anything else is 422 unsupported_protocol
result Yes A non-empty string, at most 65536 bytes; else 422 invalid_result. Must be JSON containing the three reserved keys
resultHash Yes Lowercase hex, exactly 64 chars: /^[0-9a-f]{64}$/, else 422 invalid_resultHash
result.matchId Yes Integer; must equal the match id in the URL
result.sessionNonce Yes Non-empty string; must equal the live match's nonce
result.winner Yes 17 digits: /^\d{17}$/; must resolve to a player on one of the two sides
clientRole Optional "host" or "guest" (metadata only; anything else is dropped)
startedAt Optional ISO-8601; dropped if not a valid datetime; never hashed
endedAt Optional ISO-8601; dropped if not a valid datetime; never hashed

Server behavior on submit

  1. Verify the Steam ticket; resolve the submitting SteamID64.
  2. Look up the match; 404 match_not_found if the id is not an integer or the match does not exist.
  3. 422 unsupported_protocol if protocol is not braket.result.v3; 422 invalid_result if result is missing, empty, or over 64 KiB.
  4. Resolve the submitter to a side; 403 not_participant if the submitter is neither side (in team mode, if they are on neither roster).
  5. 409 already_resolved if the match is already confirmed or forfeited.
  6. 409 not_live if the match has not started, is not in live or awaiting_result, or has no sessionNonce.
  7. Re-hash the exact result bytes and compare to resultHash; 422 hash_mismatch if they differ. This catches tampering and non-determinism loudly and early.
  8. Parse result; 422 invalid_result if it is not a JSON object with a valid matchId (integer), sessionNonce (non-empty string), and winner (17 digits).
  9. 422 result_not_for_match if result.matchId is not this match or result.sessionNonce is not this match's nonce (the anti-replay bind).
  10. Resolve result.winner to a side; 422 invalid_winner if it belongs to neither side.
  11. Idempotency: if this side already submitted the identical hash, return duplicate; a different hash from the same side is 409 already_submitted (refused, never used to flip the result).
  12. Reconcile against the other side.

Reconciliation and the success body

A successful submit returns 200 with:

{ "status": "<outcome>", "matchStatus": "<match status>" }

status (the reconciliation outcome) is one of:

status Meaning Resulting match status
pending This is the first submission; waiting for the other side awaiting_result
confirmed Both sides submitted and their hashes agree confirmed (resultSource: 'game_client')
disputed Both sides submitted but their hashes disagree disputed
duplicate This side re-submitted the identical hash it already sent unchanged

A lone submission parking in awaiting_result means the existing manual grace machinery still applies: if the second submission never arrives, the match is treated like an uncontested player report and auto-confirms after the grace period (see Result verification: the resultSource model). A session that ends with no result (disconnect, rage quit) submits nothing and falls back to the manual report/dispute path.

Complete error and status reference

HTTP Code Cause
401 missing_ticket No or malformed Authorization: Steam header
404 match_not_found Match id not an integer, or no such match
403 not_participant Submitter is neither side of the match
409 already_resolved Match already confirmed or forfeited
409 not_live Match not started / not live or awaiting_result / no nonce
409 already_submitted This side already submitted a different hash
422 unsupported_protocol protocol not braket.result.v3
422 invalid_result result missing/empty/too large, or not JSON with the three reserved keys
422 invalid_resultHash resultHash not 64 lowercase hex
422 hash_mismatch Server-recomputed hash of result differs from resultHash
422 result_not_for_match result.matchId / result.sessionNonce do not match this match
422 invalid_winner result.winner is not a player on either side

Body shape for an error is { "error": "<code>" } with the HTTP status above.

Worked example

Match 842, nonce c3f1a4e89b02d715, the slot-1 player 76561197977425772 beats the slot-2 player 76561198085460772 by 2-1 in 17 turns, with session seed 0451d2be. The game defines these fields (score, turns, seed are its own; only matchId, sessionNonce, winner are reserved).

The serialized result bytes (compact, fixed key order, no trailing newline) are:

{"matchId":842,"sessionNonce":"c3f1a4e89b02d715","winner":"76561197977425772","score":[2,1],"turns":17,"seed":"0451d2be"}

SHA-256 of that UTF-8 string is:

b559ce48611abce4b20ceff0b7dbd70470c9258686b9dedd4c6c81d81119b88d

That is the exact resultHash both clients must send. If yours differs, your two clients are not serializing byte-identical JSON (a different key order, extra whitespace, a non-canonical number, or an included timestamp are the usual causes) and the server will return 422 hash_mismatch — or, if the two clients differ from each other, the match will go to a dispute. Fix determinism until both clients print the same bytes.

Deterministic seed and deeper attestation

Give every ranked match a replicated deterministic seed set by the host at match start, drive all shuffle/RNG from it, and include it in your result. Because it is inside the hashed bytes, a host cannot silently reroll draws mid-game without producing state the guest's client contradicts. The game-defined portion of the result is the natural home for deeper attestation too: a hash-chained per-turn event log placed in result would make even collusive fabrication require a fully rules-consistent fake game that the opponent also signs, at no change to this protocol.

For competitive fairness, prefer the Braket-issued seed over a host-generated one: the discovery response (Step 1) carries gameSeed — a neutral integer minted server-side when the match goes live (currently 32-bit, carried as int64). Using it as your shuffle seed means a player-host cannot pick a favorable deal. Casual matches, which have no gameSeed, keep a host-generated seed.

Match recordings and replays

A recording is a herd.replay.v1 file — a public-outcome event log plus the recorder's own hand (delayed) and pointer stream — that Braket stores so past matches can be replayed. Bytes never transit the API: you request a presigned upload target, PUT the gzip object to it directly, then finalize. Independent of the AI referee.

Upload the gzip object with Content-Type: application/gzip; recordings are capped at maxBytes (2–16 MB). Scope full requires both players to contribute their own delayed hand, so it is only sound with the delay in place.

Per-file size follows the arena's plan: Free 2 MB · Indie 4 MB · Studio 8 MB (casual player recordings always use the free 2 MB cap); init returns your effective maxBytes. A 16 MB global hard ceiling applies to every upload regardless of plan — beyond it, finalize rejects with 413 and the parked object is deleted. Below the ceiling, an over-plan-cap upload is never rejected or deleted — it finalizes and is served normally during the event window (24 h). After that, fetching an over-cap file returns 402 plan_size_exceeded until the arena is on a plan that covers its size: upgrading retroactively restores access to everything already stored.

Near-live segments let watchers follow a match while it is still running: instead of one whole file, the client uploads numbered herd.replay.v1 fragments whose header additionally carries segmentSeq and baseTMs. POST /api/v1/recordings/:id/segments/init (body { seq }, uploader-only, idempotent per segment) returns a presigned upload target — once the parent recording's classic finalize has landed the feed is over and any further segment init returns 409 recording_ended; after the PUT, POST /api/v1/recordings/:id/segments/:seq/finalize validates the fragment the same way as a whole recording. Watchers poll GET /api/v1/recordings/:id/segments?after=<k> — it returns { state, segments: [{ seq, url }] }, where state flips from live to ended once the recording's classic finalize lands. Full-scope segments are release-gated individually: each becomes fetchable only castDelaySeconds after its own upload, and not-yet-released segments are simply omitted from the list.

When the arena runs the AI referee and both players have given training consent, finalizing a match recording also derives the referee's public gameplay trace (braket.gameplay.v1) from it server-side — one upload feeds both replay and the referee, and a separate gameplay-trace submission is no longer required (the endpoint still works for older clients).