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.
- Protocol tag:
braket.result.v3. - Reserved keys Braket reads out of your JSON:
matchId(number) — the Braket match this result is for.sessionNonce(string) — the per-match nonce from discovery, verbatim.winner(string) — SteamID64 of any player on the winning side.
- Everything else in the object is yours: scores, per-player stats, placements, board state, a per-turn event log, whatever you want to attest. Braket stores it verbatim for admin forensics but never parses it.
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.
The client mints a fresh ticket per call with
ISteamUser::GetAuthTicketForWebApi("braket")and sends it as a header:Authorization: Steam <ticket-hex>The server verifies it with
ISteamUserAuth/AuthenticateUserTicketusing the arena's publisher Web API key,identity=braket, and the arena's AppID, accepting onlyresult == "OK",ownersteamid == steamid, andvacbanned == false. The returnedsteamidis the authenticated submitter; the client never states its own identity.An arena can accept tickets from several AppIDs (a base game and its demo are distinct Steam apps, and a ticket only verifies against the app that issued it). The server tries each accepted AppID in turn. The client may send the optional header
X-Steam-AppId: <appid>(the appid it is running under, e.g.ISteamUtils::GetAppID()) so its appid is tried first — one Steam call instead of several. The header is only an ordering hint: values outside the accepted list are ignored, and a wrong value cannot authenticate anything because Steam refuses tickets checked against the wrong app.In mock mode the server accepts
Authorization: Steam MOCK:<steamId64>for local development.A missing or malformed ticket returns
401 missing_ticket.
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:
204 No Content: this Steam user has no live tournament match. Play casually, submit nothing.200 OK: a live match exists. Body:
{
"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:
- Serialize the same key order on both clients. The simplest guarantee is to build the object field-by-field in a fixed order in code rather than iterating a map.
- Only include fields both clients agree on. These are host-authoritative, replicated session facts (winner, scores, turn count, the replicated
seed). Do not include wall-clock timestamps or per-client-local values insideresult; they belong in the submission metadata (startedAt/endedAt), which is not hashed. - Use canonical numbers (no
2.0vs2), no insignificant whitespace differences, and UTF-8.
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
- Verify the Steam ticket; resolve the submitting SteamID64.
- Look up the match;
404 match_not_foundif the id is not an integer or the match does not exist. 422 unsupported_protocolifprotocolis notbraket.result.v3;422 invalid_resultifresultis missing, empty, or over 64 KiB.- Resolve the submitter to a side;
403 not_participantif the submitter is neither side (in team mode, if they are on neither roster). 409 already_resolvedif the match is alreadyconfirmedorforfeited.409 not_liveif the match has not started, is not inliveorawaiting_result, or has nosessionNonce.- Re-hash the exact
resultbytes and compare toresultHash;422 hash_mismatchif they differ. This catches tampering and non-determinism loudly and early. - Parse
result;422 invalid_resultif it is not a JSON object with a validmatchId(integer),sessionNonce(non-empty string), andwinner(17 digits). 422 result_not_for_matchifresult.matchIdis not this match orresult.sessionNonceis not this match's nonce (the anti-replay bind).- Resolve
result.winnerto a side;422 invalid_winnerif it belongs to neither side. - Idempotency: if this side already submitted the identical hash, return
duplicate; a different hash from the same side is409 already_submitted(refused, never used to flip the result). - 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.
POST /api/v1/matches/:id/recording/init(participant, Steam-ticket) — body{ scope: "public"|"full", gameBuild, durationMs }. Returns{ recordingId, upload: { url, local }, maxBytes }. Idempotent per (match, uploader).upload.localistrueonly in dev/mock, whereurlis a Braket route instead of a presigned S3 URL.POST /api/v1/me/recordings/init— same, for a casual (non-tournament) game; scope is forcedpublic, visibilityprivate, and a per-account free-tier quota applies.POST /api/v1/recordings/:id/finalize— marks the recording ready after you have uploaded the object. The server validates the object by a bounded read: it must be aherd.replay.v1gzip whose headermatchId/sessionNoncematch this recording, and the truebyteSizeis derived server-side (no request body needed). A missing (422 upload_missing), malformed (422 invalid_recording), swapped (422 header_mismatch) or oversized (413) object is rejected.GET /api/v1/me/recordings— recordings you uploaded, plus tournament recordings of matches you played.GET /api/v1/recordings/:id— returns{ url }, a presigned GET. Full-scope recordings (both hands) are gated: served only after the match'sendedAtplus the tournament's cast delay, and never for a match that never formally ended (fail-closed).visibilityisprivate(uploader),arena(any signed-in member of the arena), orlink(accessible only with the unguessable?token=).POST /api/v1/recordings/:id/share— turn a recording you own into a link-shareable one: returns{ token, path }wherepathis the tokenized fetch URL. The full-scope delay gate still applies, so sharing never bypasses the hidden-info hold.
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).