Repository navigation
BluMint Quickstart
Ember is the netplay mod for Ultra Street Fighter IV. The Ember bridge is the "game service" in BluMint's game integration guide: it serves BluMint's three callbacks (player lookup, match creation, match close) and reports each match to BluMint as it is played. Players play the matches in Ember, and both players' games report every game, so results arrive without anyone typing them in.
Ember has two bridges on its server, each with its own bridge ID, keys and
data, so no data on staging reaches production. They share nginx's general
request limits per address, so heavy staging traffic from one address can get
that address's production calls refused with 429; BluMint's three callbacks
have limits of their own on each bridge, so a staging load test does not
spend production's. Each has its own
BluMint connection (tenant blumint, kind blumint), credentials and
registration: production for BluMint's production, and staging for BluMint
staging and its testers. The staging bridge is up; production's BluMint
connection is not ready yet (see Not built yet).
| Production | Staging | |
|---|---|---|
| Bridge | https://bridge.embernetplay.link |
https://bridge-staging.embernetplay.link |
| Bridge ID | brg_0dbc0598-2312-4ce3-9df8-e160330565e6 |
brg_1195478b-ac7e-4bc9-90af-c74eac5c747b |
| BluMint API | Game partner API 2.0.0 at https://app.blumint.com/api
|
Game partner API 2.0.0 at https://staging.blumint.io/api
|
| Player lookup | POST https://bridge.embernetplay.link/v1/blumint/lookup |
POST https://bridge-staging.embernetplay.link/v1/blumint/lookup |
| Match creation | POST https://bridge.embernetplay.link/v1/blumint/matches |
POST https://bridge-staging.embernetplay.link/v1/blumint/matches |
| Match close | POST https://bridge.embernetplay.link/v1/blumint/matches/close |
POST https://bridge-staging.embernetplay.link/v1/blumint/matches/close |
| Connect page for players | https://embernetplay.link/start#brg_0dbc0598-2312-4ce3-9df8-e160330565e6/BluMint |
https://embernetplay.link/start#brg_1195478b-ac7e-4bc9-90af-c74eac5c747b/BluMint |
Production is Ember's own bridge, the one every Ember trusts by default, so players need nothing beyond Ember to use it. Nightly builds of Ember trust the staging bridge out of the box too. A tester on a Stable build approves it once under Linked accounts by its address (see Testing on staging) before connecting Discord or playing there.
The connect page opens Ember's Connect Discord screen
(ember://discord/connect?bridge=...) on Windows, and shows how to get Ember
first otherwise. BluMint shows it to a registering user that lookup finds no
Ember ID for. The /BluMint after the bridge ID is the name the page uses
for you when it speaks to the player, as in "BluMint finds you by your
Discord account"; without it the page says "the tournament you signed up
for". The page shows only a name of 1 to 40 letters, digits, spaces and
., ', & or -, starting with a letter or digit, and silently drops
any other, so BluMint (staging) would show as "the tournament you signed
up for". A player can paste either link, with the name or without, into
Ember's Paste link; an Ember from before it read the name takes only the
link without it, as the page's Copy link gives it. The bridge's own
Discord pages take the name from your tenant's name in the bridge's
configuration.
ember-bridge blumint-register calls setLookupPlayer, match/setClose
and then match/setCreate, registering the three callback URLs with a new
credential as authentication.secret. The create URL goes last because
registering it turns on automatic lobbies for the game's tournaments, so a
registration that fails before it leaves lobbies off. BluMint calls them with
Authorization: Bearer <credential the bridge issued>. Once all three calls
succeed, the bridge revokes the callback credential any earlier registration
handed over; a registration that fails partway revokes nothing, and running
it again finishes the change. There is nothing for
BluMint to configure for those callbacks. Reports use BluMint's API key, sent
as x-api-key; it is separate from the callback credential.
The staging bridge runs builds before they reach production, and its data may be reset.
Ember supports one sign-in method: Discord. A player connects their Discord account to their Ember ID once, from the home menu: Online play, Ember ID, then Discord. This uses Ember's own bridge, the production bridge above, by default. BluMint sends one Discord ID per call, when a player registers and before every match creation:
{ "discord": ["274220342558756145"] }and the lookup answers only discord:
{ "discord": ["emb1_..."] }with [] when that Discord account is not connected. Other sign-in methods
are ignored. The in-game ID is the player's Ember ID (emb1_...). Lookup also
links that ID to BluMint's connection so matches can be created for it, which
is why BluMint looks a player up again before each match.
If the Discord account is already connected to another Ember ID, the
bridge's page asks before moving it. It names both Ember IDs and explains
that the other ID's tournament links made through that account will end.
Moving it also ends that ID's live BluMint matches: the game in progress
counts for nobody, and the bridge posts a retryable abort with the games
already won (insufficientPlayers, below), so you restart the set from that
score. An Ember ID holds one Discord account at a time, so if the Ember ID
it moves to, or a first connection's Ember ID, already has a different
Discord account, the page names that account too: connecting disconnects
it, and its links and live matches end the same way.
Pressing Cancel in Ember also ends the sign-in on the bridge once that
request reaches it, so a page left open then cannot finish it. Ember does
not send a failed cancel again, so if it never arrives, the page can still
finish the sign-in until it lapses ten minutes after it started.
The bridge ID in the connect page's fragment is also the bridge_id in each
bridge's /.well-known/ember-bridge.json, such as
https://bridge.embernetplay.link/.well-known/ember-bridge.json.
Data use. Ember uses and keeps the IDs BluMint sends, and the Ember IDs its lookup returns, only to put those players into matches in BluMint's events, to report those matches to BluMint, and to let the event staff who chose to watch do so. It shares them with no one else and keeps them secure.
Send two teams of one player each, each player with the Ember ID lookup
returned among its identities, and an Idempotency-Key:
POST /v1/blumint/matches
Authorization: Bearer <credential>
Idempotency-Key: tA1b2-round-1-match-1{ "teams": [ { "name": "kate", "players": [ { "identities": [ { "inGameId": "emb1_..." } ] } ] },
{ "name": "sam", "players": [ { "identities": [ { "inGameId": "emb1_..." } ] } ] } ],
"spectators": [],
"matchSettings": {},
"series": { "gamesToWin": 2 } }-
series.gamesToWinis the set's length: 1 to 10 (best of 1 to best of 19). It has no default. -
startingWins, on every team or on none, is the score the set starts from. Send it when you ask for a set again: after aretryable: trueabort it is the games each team had won, and after a host reset it is 0 on every team. Each is belowgamesToWin. Every report counts it. - The bridge reads the one identity whose
inGameIdis an Ember ID. A player none of whose identities is one is a player it cannot match, which BluMint's guide answers withplayer_unlinked, so every such id is named in the answer, as you sent it. Team names,matchSettingsandspectatorsare yours, and are not read yet.
The answer:
{ "matchId": "emt_...", "matchUrl": "https://embernetplay.link/m#brg_.../emt_..." }Send the returned matchUrl to both players. It is the same for both and
safe to send in your messages: only the two assigned Ember IDs can play the
match, and anyone else's Ember finds no such match. The link itself does not
expire, but a match nobody plays does: the bridge ends it 24 hours after you
created it at the latest (see "Reports").
Every request under one Idempotency-Key answers the match that key made,
even after the match has ended, for a day. After that the key answers
409 idempotency_key_reused: the bridge keeps each match's key for as long
as it keeps the match, so a key never makes a second match, and a close finds
the match however old it is. A renamed team or changed matchSettings under
the key is the same match. Send a new key for a new match. A refusal is never stored
against the key, so send the same key again once the players are linked or
free.
| Situation | Status | Body |
|---|---|---|
| Not JSON, or not this shape; a player with no identity, or with two Ember IDs; the same player on both teams | 400 |
{ "code": "invalid_request" } |
No Idempotency-Key
|
400 |
{ "code": "invalid_request" } |
series missing, or gamesToWin not a whole number of 1 or more |
400 |
{ "code": "invalid_request" } |
startingWins on some teams only, or not a whole number below gamesToWin
|
400 |
{ "code": "invalid_request" } |
Not two teams of one player, or gamesToWin above 10 |
400 |
{ "code": "match_unsupported" } |
| Players with no link on your connection, or whose ids are not Ember IDs (such as an account of BluMint's), all of them named as you sent them | 400 |
{ "code": "player_unlinked", "players": ["emb1_..."] } |
| Players still in another match on your connection, all of them named | 423 |
{ "code": "player_busy", "players": ["emb1_..."] } |
| The key already made a different set (other players, length or starting wins), made its match over a day ago, or a close named it | 409 |
{ "code": "idempotency_key_reused" } |
The credential is missing, unknown or revoked (401), its connection is disabled or it is not a provider credential (403), or its connection is not a BluMint one (404) |
401, 403 or 404
|
the bridge's own error, which you read as final |
| The bridge cannot reach its database | 503 |
the bridge's own error |
Players are named exactly as you sent them, P1 first.
BluMint sends a round's lookups and creations at the same moment and reads a
429 as an outage, so nginx in front of the bridge budgets the lookup, the
creation and the close separately, each at 100 requests a second per address
with a burst of 1000, which covers a 512-player round start. The general
budget (20 a second, burst 100) does not apply to them.
When you no longer need a match, close it by the Idempotency-Key you
created it under, whether or not you had the creation's answer:
POST /v1/blumint/matches/close
Authorization: Bearer <credential>{ "idempotencyKey": "tA1b2-round-1-match-1", "reason": "decided" }The match that key made ends, its players are free, and nothing more is
posted for it. Every reason closes the match, decided, replaced,
removed and hostOverride as well as one the bridge does not know, or none;
the first 64 characters of its text are kept for the log. A match that already ended answers 200 too, and a second
close answers what the first did. The close also closes the key: every later
creation under it answers 409 idempotency_key_reused, also when the bridge
never made a match under it, so a creation still on its way makes nothing. A creation
the bridge would refuse anyway, such as an invalid body or a set longer than
Ember plays, still answers that refusal (400).
The bridge keeps a match's key for as long as it keeps the match, so a close
finds the match however long ago you created it. A key that made no match
stays closed for a day after its close. A close of a key the bridge holds
nothing under for your connection answers as a match that already ended
does, every time you send it. The close's own Idempotency-Key is not read.
| Situation | Status | Body |
|---|---|---|
| Closed, or already ended; or the key made no match, its creations refused or none received | 200 |
{ "closed": true } |
A key the bridge cannot hold a match under (empty, longer than 128 bytes, or not printable ASCII), which holds nothing and records nothing; any reason, or none |
200 |
{ "closed": true } |
Not JSON, or no idempotencyKey at all (a matchId alone included): not a close, and BluMint never sends one |
400 |
{ "code": "invalid_request" } |
The credential is missing, unknown or revoked (401), its connection is disabled or it is not a provider credential (403), or its connection is not a BluMint one (404) |
401, 403 or 404
|
the bridge's own error, which you read as final |
| The bridge cannot reach its database | 503 |
the bridge's own error |
Each bridge posts to its BluMint API's /tournaments/match/submit, such as
https://staging.blumint.io/api/tournaments/match/submit from the staging
bridge, with that environment's BluMint API key. Every report names the
creation that made the match by its Idempotency-Key, as idempotencyKey,
so a report that reaches you before you have recorded the creation's answer
gets 503 match_not_ready and is sent again. Every report names each
player by the Ember ID you sent. Each team's wins, in a progress report, a
result and an abort, is the set's running total after that game, counting
the startingWins the match was created with, never that one game's outcome
alone.
A status report each time the match's status changes: as each player
opens the match in Ember or leaves it, presses Ready or takes it back, as
each game starts, and as each game ends with another to come. A match nobody
has opened has none.
{ "mode": "status", "matchId": "emt_...", "idempotencyKey": "tA1b2-round-1-match-1",
"status": "running",
"revision": 3, "observedAt": "2026-10-16T18:04:12Z", "players": [
{ "inGameId": "emb1_...", "status": "present-ready" },
{ "inGameId": "emb1_...", "status": "present-ready" } ] }status is pending until the players' first game starts and running
from then on, whatever games an organizer recorded. Each player is:
status |
When |
|---|---|
absent |
Their Ember is not connected to the match: it has not opened it, or stopped claiming it for 136 seconds |
present-not-ready |
Their Ember is connected to it and they have not pressed Ready for the next game, or took it back or left their seat; or it was refused because its build differs from their opponent's. Never while a game is being played |
present-ready |
They pressed Ready for the next game, or a game is being played, from its start until both games report it |
Each player's Ember says whether they have pressed Ready every time it
claims the match, and claims again as soon as that changes, at most once
every two seconds, so a player waiting for an opponent who never presses
Ready reads present-ready and the opponent present-not-ready. A played
game's end clears both seats at the table, so both read present-not-ready
until they press Ready for the next game. A game called off because one
player took Ready back leaves the other's Ready as it was. An Ember from
before this release does not say, and its player reads present-not-ready
until a game starts.
A refused Ember stops claiming the match, so the bridge cannot tell whether
that player stays: they read present-not-ready for the rest of the match,
until they open it again on their opponent's build, except while a game is
being played, when they read present-ready like every present player. Time the bridge itself
is down does not count toward those 136 seconds.
revision is an integer that rises with each change on the match, and
observedAt is when the bridge saw it. A report that waited is sent as the
status then stands, so keep the one with the highest revision and drop one
that arrives after it. Nothing is sent once the match has ended.
After each game won that does not end the set. In a first to 3, once the first team has won two games and the second one:
{ "mode": "progress", "matchId": "emt_...", "idempotencyKey": "tA1b2-round-1-match-1",
"teams": [
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 2 },
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 1 } ] }When the set is won, here by the first team's third game:
{ "mode": "result", "matchId": "emt_...", "idempotencyKey": "tA1b2-round-1-match-1",
"teams": [
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 3 },
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 1 } ] }A draw or an abort both games agree on changes no score and posts nothing. An organizer voiding a game lowers the score. The void posts nothing itself, but a progress report still waiting to be sent goes out with the lowered score. Once the bridge has tried to send a progress report on the set, it refuses to void any game of it: you refuse a report below the wins an earlier one gave a team, and the set could never be reported again. When a match ends without a result, the bridge posts an abort with the wins so far:
{ "mode": "abort", "matchId": "emt_...", "idempotencyKey": "tA1b2-round-1-match-1",
"reason": "disputed", "retryable": true, "teams": [
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 1 },
{ "players": [ { "inGameId": "emb1_..." } ], "wins": 0 } ] }| How the match ended | reason |
retryable |
|---|---|---|
| The two games disagree about a game, or one never reports it | disputed |
true |
| Neither game reported a game, or a player's link ended | insufficientPlayers |
true |
| Called off another way, such as by the bridge's operator | serverFailure |
true |
| Still open when it expires: a lifetime after its last game with none being played, or 24 hours after its creation whatever is being played | insufficientPlayers |
false |
BluMint has no review step, so a match that would wait for one is ended and
posted as a retryable abort, and you ask for the set again under a new key
with startingWins. A match that expired is not worth asking for again, so
its host takes it. Each game started or decided gives the match another
lifetime from then, but never past 24 hours after its creation, since you
free a player held by a match you could not close only for 24 hours after
it was made. At that time the match ends even with a game in progress,
which is called off.
| BluMint answers | The bridge |
|---|---|
200 |
is done |
409 (already_scored, match_closed) |
is done |
503 match_not_ready with Retry-After
|
tries again, no sooner than Retry-After
|
500, another 5xx, 429, a timeout or no connection |
tries again |
401 or 403 (a key you rotated, or bound to no game) |
tries again, so the reports go out once the key is fixed |
404 unknown_match |
stops, a status report included (BluMint says to stop without alerting) |
Another 4xx
|
stops |
The bridge tries again on a schedule from a second to an hour, for a day. Once it starts posting a result, the result is final on the Ember side too, so BluMint never receives a second, different result for a match.
-
On BluMint: use Discord player lookup for the test tournament, once
Ember has registered the three callbacks on the staging bridge. Create
matches with the Ember IDs returned by lookup and send players the
returned
matchUrl. -
Trust the staging bridge (Stable builds only): a Nightly build of
Ember trusts it already. On a Stable build, choose Online play > Ember
ID > Linked accounts, enter
https://bridge-staging.embernetplay.linkunder Add a service, choose Look up service, then Trust it. -
Connect Discord: open the staging connect page (
https://embernetplay.link/start#brg_1195478b-ac7e-4bc9-90af-c74eac5c747b/BluMint) and choose its button, or paste its link into Online play > Ember ID > Discord > Paste link. Not the Discord row by itself: that connects Discord on the production bridge, where BluMint staging's lookup finds nobody. Sign in with the same Discord account used on BluMint, then return to BluMint to register again. -
Play: assigned matches appear automatically, with a notice and a
Tournament matches row on Home, and a list under
Online play > Ember ID > Tournament matches. Players can also paste
the
matchUrlthere. Open the assigned match and press Play. Check the reports and the result on BluMint.
-
Production.
https://bridge.embernetplay.linkhas no production BluMint connection yet: one reporting tohttps://app.blumint.com/apiis not deployed or registered, so BluMint production cannot call it. Its oldblumint-partner-stagingconnection is no longer called, since BluMint staging is registered on the staging bridge. - Tested on real hardware. Automated tests cover tournament play, but a two-PC match on real hardware has not been done yet.
-
Watching.
spectatorsis sent and not read yet; the listed staff cannot watch a match through Ember. -
Forfeits. Ember sends no
terminationreport, because it has no concede action. A player who quits is not reported as a forfeit; the match ends as an abort, and BluMint's own hold decides what happens to a player who does not come back.
-
Field names. BluMint sends each player's Ember ID as an identity's
inGameId, and reads it there in every report. -
Status. BluMint never calls a status URL. The bridge registers none,
and posts a
statusreport each time a player arrives or leaves, a game starts, or a game ends with another to come (see Reports). -
Restarts. A match that ended without a result is an abort with
retryable. BluMint asks for the set again under a new key after a retryable one, carryingstartingWins. -
Unplayed matches. An expired match is an abort with
retryable: false, and BluMint's host takes the match.
Generated from the docs at cf0c5c83c; edit the docs, not this page.
Coming soon, from open pull requests:
- #51 Allow reading and sending chat directly in the room
- #76 Endless lobby music at the main menu, with a setting
- #90 Add a Linux guide for installing Ember by hand under Proton
- #100 Let spectators toggle the lobby with Start
- #102 Make table actions controller accessible and consolidate room settings
- #104 Expire chat notifications after four seconds and retain unread badges
- #105 Add LAN room discovery and direct local connections
- #108 Install Ember on a Steam Deck with a double-click
- #111 Let older copies take new package files, setup upgrade an Ember folder, and switch channel in game
- #116 Keep Ember's menu confirm on A and B for every XInput pad
- #133 Add the Ember ID passphrase and keyboard tips to the Steam Deck guide
- #139 Show the watched game in the room column, with a toggle on the board
Getting started
Playing online
Training
Frame Data
Integrations
Troubleshooting
More guides