Repository navigation
Integrations
Ember's tournament bridge is a provider-neutral web service. Tournament platforms, organizers and bots talk to it over one HTTP and event contract, and it never relays gameplay. This guide covers what works in this branch: identity linking, matches played in Ember and reported by the game, matches decided by an organizer, and signed events for webhooks, Discord and Twitch.
The full design is EMBER-TB-001 in docs/design/identity-bridge/. What is
implemented, and how it differs from the draft, is in STATUS.md there.
- Each player has a persistent Ember ID, created in the game on request and
kept under
%LOCALAPPDATA%\Ember\Identity\v1. It survives restarts and updates, and an encrypted backup carries it to another PC. - A bridge links that ID to an account on your platform with a short code that the account holder approves.
- Matches are created by a provider. A match can be played in Ember, where the
game opens a room for the two players, keeps everyone else out, and reports
each game's result itself (
ember-room-v1), or decided game by game by an organizer (organizer-reported-v1). - Every match played in Ember has a Play link, the same for both players, that opens it in Ember or shows how to install Ember first.
- On top of matches, the bridge can run king-of-the-hill lobbies and whole tournaments (single elimination, double elimination, round robin), and it keeps each player's record of sets and games.
- Every change is an event, delivered to webhooks and readable by cursor or over SSE.
- A platform connects as a
directprovider: it calls this API itself with its provider credential. Themockkind adds browser login pages for local testing. Theblumintkind serves BluMint's game partner API instead (see "BluMint" below). - Optionally, a player connects their Discord account to their Ember ID, so a platform that finds players by Discord account gets their Ember ID. The same goes for an account on any sign-in site the operator configures, such as Challonge (found by username) or start.gg (found by user ID).
- A bracket run on Challonge, start.gg or a service of your own plays in
Ember through
ember-brackets, which turns each open set into a match and reports the score back (see "Bracket sites" below). - A connection's settings give any platform what BluMint has: finding players by Discord account, results sent to it, and disputed matches restarted rather than reviewed. With rooms allowed, a platform or bot opens public rooms for its players and hears what happens to them (see "Connection settings" below). The notifier is a ready-made room bot for Discord and Twitch.
- Ember's production bridge runs at
https://bridge.embernetplay.link, and a staging bridge with its own bridge ID and data athttps://bridge-staging.embernetplay.link. Platforms get a connection and credentials from the Ember team; BluMint starts fromBLUMINT_QUICKSTART.md.
From server/ember (the bridge build needs the Visual Studio C++ tools for its
bundled SQLite):
cargo run -p ember-bridge -- init ../../bridge-dev
cargo run -p ember-bridge -- credential ../../bridge-dev/bridge.json provider mock-local "my platform"
cargo run -p ember-bridge -- credential ../../bridge-dev/bridge.json organizer local "my organizer"
cargo run -p ember-bridge -- serve ../../bridge-dev/bridge.jsoninit writes bridge.json, a fresh bridge-secrets.json and a mock provider
connection, listening on http://127.0.0.1:8787. Each credential command
prints a token once; the bridge keeps only a keyed hash. Keep
bridge-secrets.json private. Players never need it.
Loopback http is accepted only because init sets allow_loopback_http
and allow_private_webhooks for local work. A deployed bridge uses an
https origin with both off, and mock_browser off.
server/ember/bridge/deploy holds the staging deployment: a systemd unit, an
nginx site, a daily backup and setup.sh.
bridge.json also takes match_expiry_hours, how long a match nobody plays
stays open (24 when absent; 1 to 336, which is 14 days). See "Matches nobody
plays" below.
- Your service, having verified who the user is, creates a link intent:
POST /v1/link-intentswith{"subject": "<your user id>"}and a provider credential. Show the returned code to that user only. It works once and expires after five minutes. - The player enters the code in Ember (Profile, Ember ID, Linked accounts). Ember's helper proves it holds the key and submits a claim.
- Your service shows the claim's fingerprint (
GET /v1/link-intents/{id}) next to the one Ember shows, and when the user confirms it, approves that exact claim:POST /v1/link-intents/{id}/approvewith the claim ID, the Ember ID and your subject.
A code on its own never completes a link; someone who copies it can only
create a claim that the account holder sees and rejects. To try the browser
version of this flow, open http://127.0.0.1:8787/mock/mock-local/login.
POST /v1/players/resolve maps your subjects to their current Ember IDs.
To test linking without the game, sdk/typescript/tools/test-player.ts stands
in for a player: it holds a throwaway Ember key and claims a code the way Ember
does (TestPlayer in the SDK does the same from code).
sdk/typescript/tools/walkthrough.ts runs linking, a match and its events
end to end against any bridge.
-
POST /v1/matches(provider,Idempotency-Keyrequired) takes the two linked participants, the set length (games_to_winof 1 to 10) and the rules profile:ember-room-v1for a match played in Ember (next section) ororganizer-reported-v1for one an organizer decides. Retrying with the same key or the sameexternal_match_idreturns the same match. -
POST /v1/matches/{id}/adjudications(organizer only) records one game:{"kind": "game_result", "winner_slot": 0, "reason": "...", "expected_revision": "1"}, or"draw": true, which scores nothing. When a player reaches the set length the match completes.{"kind": "void_game", "attempt_id": "..."}removes a game and scores the match again. On a completed match that is a correction: it stays completed while a player still has enough wins, and otherwise reopens. Only a correction that would reopen the match can be refused (lease_conflict): when either player has another match currently active on the same connection, for a lobby set the lobby has moved past, or for a bracket set whose later sets have games. Corrections in a finished tournament are always refused. Readstateandrevisionfrom the answer, and handle a refusal, before changing your own result. -
POST /v1/matches/{id}/cancelcancels with a reason.
Every write names the revision it expects, as the decimal string the match shows, so two people acting at once cannot both win.
A match does not wait forever. Its created_at and expires_at (unix
seconds) are on GET /v1/matches/{id} and in the players' assignments, and
expires_at is the match's current deadline: match_expiry_hours after
creation, a day unless the operator changed it, and again that long after each
game that starts or is decided. On a BluMint connection games move it the
same way, but never past 24 hours after the match's creation, whatever
match_expiry_hours says. A provider cannot choose it per match yet.
Once expires_at has passed, the bridge's maintenance pass (every ten
seconds) moves the match to the terminal state expired, frees its players,
and writes match.expired (state, reason: "not_played", expires_at,
match_revision). expired is not cancelled: nothing called the match off,
and it is not a request to play it again. Whether to schedule it again is your
decision. Ember never creates a replacement.
A match is only expired when nothing is happening in it:
- no game is open (started and waiting for its reports) and no player's game holds the room's provisioning lease;
- a match with games recorded, such as a set at 1-0 that the players left,
expires only after a whole lifetime with no game started or decided (a game
started, or settled by agreeing reports, an organizer's result, a voided game
or a correction), so
expires_atmoves to a full lifetime after each of those and a set being played is never cut off, even when an organizer decides a held game after the old deadline (on a BluMint connection, see above, never past a day after creation); - a match waiting for an organizer in
needs_reviewnever expires, since a person owns it; - a BluMint match expires 24 hours after its creation whatever is happening in it, and a game still open then is called off, since BluMint counts its players free from then;
- lobby and tournament sets never expire (their
expires_atis null); they end with their lobby or bracket.
An expired match cannot be cancelled or adjudicated, and it stays in the
players' assignments, as expired, for a day after it expired. Matches that
were still open when this was added got a day from the later of their creation
and their last update, so old stale ones expired the first time the bridge ran
its maintenance.
A platform with a results_url is sent the result with outcome expired
(no winner; scores are what was recorded), never restart. BluMint is sent an
abort it must not retry (retryable: false), so its host takes the match.
With native_rules_profile: "ember-room-v1" the players play the set in Ember
and the game reports it:
- Each player opens the match in Ember: from the Tournament matches screen (Settings > Ember ID), or from your Play button (below). The first player's game opens a room and the second joins it. Only the two assigned players can be in that room, and the room plays the set length you chose.
- Before each game both games ask the bridge for permission to start it, and
the game starts only when both have it. You see
match.room.readyonce the room is published, thenmatch.attempt.authorizedfor each game. - When a game ends, each player's game reports what it saw, signed with that
player's Ember ID (
match.game.reported). When both agree the game counts (match.game.confirmed,match.score.changed), and at the set length the match completes (match.completed). - When the reports disagree, only one arrives within a minute, or a started
game is never reported, the match waits in
needs_review(match.needs_review). An organizer then decides that game with an adjudication naming itsattempt_id(from the error detail or the match), or voids it so it is played again. Nothing is guessed: a missing report never becomes a win for the other player.
Signed reports show which Ember ID said what. Two players who agree on a false result can still both report it, so treat them as the players' own account of the game, not as proof against cheating.
Both players need the same Ember build; the second player's game is refused
with incompatible_build otherwise. required_build_id is your label for the
build and is not checked against the game.
The answer to POST /v1/matches, and GET /v1/matches/{id}, carry the
match's play_url: https://embernetplay.link/m#<bridge id>/<match id>. It is
the same for both players and safe to put in messages: only the match's two
assigned Ember IDs can claim the match, and anyone else's Ember finds no such
match in their list. The link itself does not expire, so you can store it;
the match it opens does if nobody plays it (see "Matches nobody plays").
The page asks the player to choose Open in Ember, which hands Ember
ember://tournament/open?bridge=...&match=.... Below that it shows how to get
Ember when it is not installed yet (download, start it once, come back to the
same link), so your site needs no explanation of its own. Ember opens the
Tournament matches screen on that match and the player presses Play there; a
player in a game or a room is never moved. Where the ember: link does not
open Ember (Linux browsers, or a PC where Ember has not been started yet), the
page's Copy link and Paste match link on that screen do the same, or the player
picks the match from the list.
An organizer, a caster or another approved viewer can watch a match played in
Ember (ember-room-v1) from inside the match's own room, without being able
to affect the set. The bridge names a watcher only when both players' games
say they can read one (a current Ember's claim carries "watch": true, which
a game sends only to a bridge that lists matches.watch) and the watcher's
Ember build is the one both players run. Matches played on an Ember that
cannot read watchers, such as Stable 1.3.0, never get a watcher, whatever a
watcher declares. A bridge that serves watching lists matches.watch in its
capabilities features.
Who may watch is the organizer's choice, and the default is nobody, so a connection that does nothing changes nothing:
- Staff. Ember IDs you approve for every match of your connection, such as a TO or a caster. They can always watch.
- Link holders. Off by default. When you turn it on, anyone who holds the match's Play link and has an Ember ID may watch, up to 2 of them.
- Denials. An Ember ID you deny for one match is out of it, staff or not.
- At most 6 watchers watch one match, staff included. A staff watcher can take the place of a link holder when the match is full.
BluMint's connections cannot be opened to watchers: every write below answers
403 forbidden with details.reason connection_fixed, and spectators in
BluMint's create request stays ignored.
Address exposure. A watcher joins the players' room, and the members of a
room can learn each other's public IP addresses when Iroh finds a direct path.
A watcher reaches player 1 (the game host) and the room's coordination leader,
which is either player. With link_holders on, anyone who has the match link
and a free Ember ID can learn a competing player's address in the middle of
your event, which invites a flood from outside Ember. Staff, whom you approve
by name, have the same reach. Ember cannot send only a watcher's links through
its relay. Turn link_holders on only if you accept that, and tell your
players.
What a watcher can do. Nothing in the room but watch: no seat, queue, rules, chat, host powers or vote, and they never hold a game start. They leave by leaving the room. Each needs an Ember ID, made in the game in a moment, with no linked account.
How a watcher gets in. The match's Play link opens Ember, which offers
Watch to anyone who is not one of the players. The game asks the bridge every
10 seconds (POST /v1/matches/{id}/watch, a match.watch proof from the
watcher's own session) and is answered waiting with a reason, or room
with the room and its invitation. The reasons are no_room (no room is
published yet), not_bound (a player has not claimed, or the players' games
do not both read watchers) and full (the cap or ceiling is reached, another
watcher holds the same endpoint, or the endpoint is a player's own; ask
again). It joins that room, and the players' next claims carry a binding that
names it, so a change of policy, a denial or a watcher who goes quiet (60
seconds) reaches the room within about one claim interval of the players (10
seconds). A watcher is never given a binding. Refusals are forbidden with
details.reason not_allowed, revoked or fighter, incompatible_build when
the players run another Ember build, and rate_limited above 30 calls a minute.
Organizer routes (organizer credential of the connection's tenant; the connection's provider credential may read):
| Route | Does |
|---|---|
GET /v1/connections/{id}/watch |
link_holders, link_holder_cap (2), ceiling (6) and the staff list |
PUT /v1/connections/{id}/watch |
{"link_holders": true}. Read the address exposure above first. |
PUT and DELETE /v1/connections/{id}/watch/staff/{ember_id}
|
Approve, or stop approving, an Ember ID. The PUT body is {} and the answer {"ember_id", "added_at"}; DELETE answers 204. |
PUT and DELETE /v1/matches/{id}/watch/denials/{ember_id}
|
Deny, or allow again, one Ember ID for one match. The PUT body is {} and the answer {"ember_id", "set_at"}; DELETE answers 204. |
GET /v1/matches/{id}/watchers |
Everyone who asked to watch: via (staff, link_holders, denied, none), whether the binding names them (bound), first and last seen, and the denials |
Every write is audited with the Ember ID it names (on or off for
link_holders). Two watchers who ask for one endpoint get one place, the
staff one or else the later arrival, and the loss is audited as
watch.endpoint_conflict. The play_url of the match is the link watchers open,
and no event kinds were added. The TypeScript SDK has watchPolicy,
setWatchLinkHolders, addWatchStaff, removeWatchStaff, denyWatcher,
clearWatchDenial and matchWatchers, and TestPlayer has watchMatch and
stopWatching.
A bridge whose configuration has discord (the Discord application's
client_id, with <origin>/v1/discord/callback in its redirect list) and
whose integration secrets hold the client secret lists discord in its
capabilities features. A player then connects their Discord account from
Ember (Online play, then Ember ID, then Discord). A platform can send the
player there directly: the link ember://discord/connect?bridge=<bridge_id>,
offered by the page https://embernetplay.link/start#<bridge_id>, opens
Ember's Connect Discord screen for that bridge. At an idle menu it creates the
Ember ID if needed, trusts Ember's own bridge unless the player removed it,
and opens Discord's page by itself; a link that arrives during play asks
first. A platform may add its own name after the bridge ID
(/start#<bridge_id>/<name>, such as /BluMint), which the page shows the
player as the site that finds them. The page shows a name of 1 to 40
letters, digits, spaces and ., ', & or -, starting with a letter or
digit (percent-encoded in the link, as %20 for a space); it drops any
other name without saying so and speaks of "the tournament you signed up
for" instead. It keeps only the bridge ID in its address and in the link it
copies. The bridge's Discord pages name the sites that find players by
Discord account by their tenants' name.
Where the browser cannot open Ember, the player pastes the page's link into
Connect Discord (Paste link) instead. Ember reads the link with or without
the name, though an Ember from before it read the name takes only the link
without it, as the page's Copy link gives it. The link names a bridge only;
Ember never trusts one by itself. The sign-in:
- Ember's helper calls
POST /v1/discord/startwith adiscord.connectproof and opens the returned Discord page in the browser. - Discord sends the browser to
GET /v1/discord/callback. The bridge exchanges the code itself, with theidentifyscope only, and keeps the Discord user ID and username. The sign-in is one-use and lapses after ten minutes. Starting a sign-in ends the Ember ID's earlier ones, andDELETE /v1/discord/start(adiscord.cancelproof, sent by Cancel in Ember) ends the one in flight, so a page left open connects nothing. Ember does not send a cancel that failed again, so until such a sign-in lapses, its page can still finish it. - If that Discord account is connected to another Ember ID, nothing moves
yet: the page names both Ember IDs, says that the tournament links the
account made for the other one end, along with the matches they hold, and
asks. If the Ember ID being connected already has another Discord
account, the page names it and says it is disconnected and its links end
too. The player's answer (
POST /v1/discord/callback) carries a secret made for that question and shown only on that page, never the sign-in's state, which the Ember that started the sign-in also holds. It moves the account or keeps it where it is. -
GET /v1/discord(player session) shows the connected account andDELETE /v1/discord(adiscord.removeproof) disconnects it.
One Discord account belongs to one Ember ID and the other way round; the
latest sign-in replaces both, once the player confirms moving an account
from another Ember ID. A platform's links that a sign-in approved
(approved_via discord) end when that account moves to another Ember ID or
is disconnected, and the platform's next lookup links it where it now
belongs. A link a sign-in approved that the player removes stays removed:
no account the player has connected (Discord's or a sign-in site's) makes
it again on that connection, until the player signs in again with one of
them, which gives that account's consent back. Removing a link made from a
code withdraws nothing, as before. Nothing else from Discord is stored. Without
the client secret the bridge offers no sign-in and says so in its log, but
every bridge lists discord.accounts and keeps GET and DELETE /v1/discord, so a player can
always see and disconnect an account connected earlier.
Besides Discord, a bridge can offer sign-in with any site that has an OAuth
authorization-code flow, such as Challonge or start.gg. A site is data, not
code: the operator defines it in sign_in_sites in bridge.json, and a new
site needs no new bridge build, migration or game translation. Example
definitions ship in server/ember/bridge/deploy/sites/ (challonge.json,
startgg.json); copy one into sign_in_sites and set client_id:
"sign_in_sites": [
{
"id": "startgg",
"display_name": "start.gg",
"client_id": "<the start.gg application's client ID>",
"scope": "user.identity",
"authorize_url": "https://start.gg/oauth/authorize",
"token_url": "https://api.start.gg/oauth/access_token",
"token_encoding": "json",
"token_params": { "scope": "user.identity" },
"user": {
"method": "POST",
"url": "https://api.start.gg/gql/alpha",
"body": { "query": "query CurrentUser { currentUser { id player { gamerTag } } }" },
"id_pointer": "/data/currentUser/id",
"username_pointer": "/data/currentUser/player/gamerTag"
},
"username_rule": { "characters": "printable", "max_length": 64, "case_insensitive": false },
"lookup_by": "user_id",
"hosts": ["start.gg", "api.start.gg"]
}
]-
id: the site ID, 1 to 32 ofa-z,0-9and-, neverdiscord. Routes, storage, lookups and claims name the site by it, so keep it once players connect (challonge-stagingbesidechallongeis fine). -
display_name: what the pages and Ember show (Ember's row says "Connect start.gg"). -
client_id,scope: the OAuth application's. Its client secret issite_client_secrets.<id>in the integration secrets (set-integration-secret.sh site <id>), and its redirect list must hold<origin>/v1/sites/<id>/callback. -
authorize_urland optionalauthorize_params: where the browser goes, withresponse_type=code,client_id,scope(left out when empty),redirect_uriandstate, plus those parameters. -
token_url,token_encoding(formorjson) and optionaltoken_params: the code exchange, withgrant_type=authorization_code,code,redirect_uri,client_idandclient_secretin the body, plus those fields. -
user: how to read who signed in, with the token as a bearer:method(GETorPOST),url, extraheaders, an optional JSONbody(POST only, such as start.gg's GraphQL query), and JSON pointersid_pointerand optionalusername_pointerinto the answer. A body goes asContent-Type: application/jsonunlessheadersnames its ownContent-Type, which replaces it.headersnames each header once and cannot setAuthorization,Host,Content-Length,Transfer-EncodingorConnection. A whole number of zero or more is read as its decimal digits. Without a username (no pointer, or nothing there to show) the user ID is shown instead. -
username_rule:characters(letters_digits, with ASCII punctuation inalso, orprintable, anything but control characters),max_length, andcase_insensitive. -
lookup_by:usernameoruser_id, what a lookup'sidnames (see "Connection settings").usernameneeds ausername_pointer. -
hosts: every URL above must be https on one of these hosts, with no port, credentials or fragment. Each host is a public DNS name: an IP address,localhost, a single-label name or one under a private suffix such as.localor.internalis refused (local tests aside). Ember opens a sign-in page only on theauthorize_url's origin and path, as the capabilities list them. -
limits(optional):monthly_request_budget,daily_request_budgetandstarts_per_hour, below.
A definition is trusted configuration, like the rest of bridge.json: it
receives the site's client secret and decides where players' sign-in codes
and tokens go, and what the bridge stores and shows about them. Write your
own or copy the shipped examples; never load one from someone you would not
give the client secret to. The bridge checks each definition when it starts
and refuses one it cannot use. Every configured site is listed in the capabilities' sign_in_sites
(id, display_name, sign_in, lookup_by, authorize_origin,
authorize_path), with sign_in false while the integration secrets lack
its client secret. Every bridge lists the sites feature, so Ember knows
it has these routes, with or without sites. To take a site out, remove it
from sign_in_sites and from every connection's lookup_sites in the same
edit: the bridge refuses to start while a connection still names a site it
does not have. The site then offers no sign-in and drops out of
sign_in_sites and of lookups, but while accounts are still connected on
it, GET and DELETE /v1/sites/<id> keep working for the signed-in player, so a player can still see and disconnect one
(Ember lists only the configured sites, so that is through the API; the
links it approved can also be removed from the player's links). Once none is
left, its routes answer 404. Its rows come back into use if the site is
configured again under the same ID.
Sign-in works as Discord's does (above), on the site's own routes and with the same pages naming the Ember ID and the site:
- Ember's helper calls
POST /v1/sites/<id>/startwith asite.connectproof and opens the returnedauthorize_urlin the browser. - The site sends the browser to
GET /v1/sites/<id>/callback. The bridge exchanges the code, reads the user ID and username, and drops the token. - The page asks before connecting, or before moving an account another
Ember ID holds (
POST /v1/sites/<id>/callback). An account whose username the site'susername_ruledoes not allow is not connected when lookups match usernames, since no lookup could find it. -
GET /v1/sites/<id>shows the connected account,DELETE /v1/sites/<id>/start(site.cancel) ends a sign-in in flight, andDELETE /v1/sites/<id>(site.remove) disconnects the account.
Proofs are bound to the exact path, so a proof for one site's route is
refused on another's. A site the bridge does not have answers 404
not_found, and its callback shows the browser a page saying the bridge
does not offer that sign-in. Each site keeps its own accounts, sign-ins, withdrawals and
request budget. Links a site's sign-in approved (approved_via site,
claim <id>:<user ID>) follow the account exactly as Discord's do.
A site with limits is held to them; one without has only each Ember ID's
ten starts an hour. A sign-in costs two requests (the token exchange and the
user read), and the bridge spends at most monthly_request_budget per
calendar month and daily_request_budget per day, both in UTC and counted in
the database: it takes a sign-in's two before the exchange, so one the site
refuses still counts both. At either budget a start answers sign_in_busy
(503, retryable, with Retry-After until the next UTC day for the daily
budget) and a sign-in already started ends with a page asking the player to
try again later. Starts are also limited to starts_per_hour across the
bridge, counted only for starts whose proof holds and that are stored; a
start it refuses answers sign_in_busy too. The bridge logs a warning when
80% of the month's budget is spent, and one line when a limit refuses (at
most once an hour for starts_per_hour, once a day for either budget).
Challonge's free API tier allows 500 requests a month, so its example
definition sets 400 a month, 40 a day and 30 starts an hour. These limits
bound what one day can cost; they do not stop a drain. Ember IDs cost nothing
to make, so a determined actor can make throwaway ones and spend the day's
share every day, keeping that site's sign-in busy for real players until the
next UTC day. The rest of the month keeps its room for as long as a day's
share times the days left fits in it (10 days at Challonge's example
limits). The operator's levers are a smaller daily_request_budget or
starts_per_hour, a paid tier with a larger monthly_request_budget, and
the refusal lines in the log.
The bridge counts only its own requests, made with its OAuth application. ember-brackets, the bracket connector, counts the requests it makes with an organizer's key separately, in its own process. Whether a site counts the two against one allowance depends on the site.
For a site whose lookups match usernames (Challonge's), the bridge keeps the
username the site gave when the account connected, compared as the
username_rule says, and lookups match on it; the site does not tell the
bridge when an account renames. When another account connects with a name
the bridge already holds, the older account stops answering for that name
(it stays connected, and answers for whatever name it next connects with).
What this cannot catch: a player who renamed is still found under the old
name, so if someone who never connected to Ember takes that name, a lookup
for it finds the player who gave it up. Comparing the name with the site does
not help, since both had it. Each answer carries connected_at, when the
bridge last saw the account with that name: a connector can refuse names
older than it trusts, or ask its players to connect again before an event.
A site whose lookups name user IDs (start.gg's) has none of this: its usernames (start.gg gamer tags, which many players can share) are only shown, and lookups match the user ID exactly.
The form a lookup matches is stored when an account connects. Changing a
live site's username_rule (its characters, length or case) or its
lookup_by therefore does not apply to accounts already connected: lookups
by username miss them (a site switched to username stored no usernames to
match) until each player connects that account again. Plan such a change
like a new site, and ask players to reconnect.
The bridge knows players, matches and results, and names no site. Everything specific to a service is an adapter around it:
- A sign-in site (above) lets players connect their account on that service, so the service's entries can be matched to Ember IDs.
-
ember-brackets(server/ember/brackets) is a small service that runs as adirectconnection withlookup_sites(anddiscord_lookupfor start.gg's linked Discord accounts) and aresults_url. For each tournament in its config it reads the open sets, finds both players, creates anember-room-v1match with the round's games to win, marks the set underway, and reports the score when the bridge sends the result. It never writes over a set the organizer decided. - Its adapters are
challonge,startgg, andhttp. Thehttpadapter calls a service of your own with a small JSON contract, so any bracket tool written in any language can be played in Ember without changes to Ember. The adapter interface and thehttpcontract are inserver/ember/brackets/ADAPTERS.md; setup, request budgets and limits are in itsREADME.md.
A platform that would rather create matches itself keeps using the API in this guide directly; the connector is one client of it.
A connection of kind blumint serves BluMint's game partner API (2.0.0) with
the connection's provider credential as Authorization: Bearer:
-
POST /v1/blumint/lookupanswersdiscordwith the Ember IDs connected to those Discord accounts, and links each on the connection (approved_viadiscord) so matches can be created for it. An Ember ID already linked there by a code keeps that link. -
POST /v1/blumint/matchescreates anember-room-v1set from two teams of one, ofseries.gamesToWin, starting from the teams'startingWins, and answersmatchIdand the match'splay_urlasmatchUrl. Every request under oneIdempotency-Keyanswers the same match for a day, and the key is the match'sexternal_match_id(blumint:<key>), so it makes no other match for as long as the bridge keeps that one. Refusals answer BluMint's registry codes at the top level (invalid_request,match_unsupported,idempotency_key_reused,player_unlinkedand423player_busy), never the bridge's error envelope. -
POST /v1/blumint/matches/closetakes{ idempotencyKey, reason }: it ends the match made under that creation key, however old, and closes the key to every later creation. A key that made no match, its creations refused or none received, answers200as a match already over does, and is closed all the same.
The bridge reports each match to BluMint: a status with each player's
presence, under a rising revision, as each player opens or leaves the
match, presses Ready or takes it back (each claim's ready, sent to a
bridge that lists matches.claim_ready), as each game starts and as each
game ends with another to come; a
progress after each game won that does not end
the set; and the result, each team with its wins. A cancelled match is an abort with
the wins so far and retryable: true, which BluMint answers by asking for
the set again with those wins as startingWins. Every report names the
creation that made the match as idempotencyKey (its external_match_id
without blumint:), which BluMint requires. BluMint has no review step,
so a match that would go to needs_review (the two games disagree, a report
never arrives, a player's link ends) is cancelled instead. A match nobody
plays expires after a day at most and is an abort with retryable: false, so
BluMint's host takes it. Posts need BluMint's API key for the connection in
the integration secrets; a match that ends before the key is there is posted
once it is. Posts are retried for a day; the match's
provider_delivery_state says how its result went. BluMint takes one result
per match, so once the bridge starts posting it (delivering) an organizer
can no longer correct it. BluMint also refuses a report below the wins an
earlier one gave a team, so once a progress report has been posted an
organizer can no longer void a game of the set. A closed match is sent nothing more. ember-bridge blumint-register <bridge.json> <connection> registers the three endpoints
with BluMint, handing over a new provider credential that is never shown.
Once all three are registered it revokes the callback credential an earlier
registration handed over; one that fails partway leaves it in place.
BLUMINT_QUICKSTART.md is the guide for BluMint's side.
A match BluMint asks for again from a score carries starting_wins in its
match.completed data, P1 first, and leaves it out at 0-0. Its scores
count those wins, so each player's wins are their starting wins plus the
games they won in this match, and accepted_attempt_ids lists only the
games played in it. EMBER-TB-001's MatchCompletedData schema does not list
the field, so a subscriber that validates events against
docs/design/identity-bridge/schemas/core.schema.json must allow it.
A blumint connection makes matches only through BluMint's create
callback; the generic match, lobby and tournament routes refuse it, so every
match on it is one BluMint knows.
BluMint's bodies are parsed as ordinary JSON, since its match settings carry decimals; every other route keeps the strict profile.
The bridge's operator gives a connection its settings in bridge.json, and
issues its credential as usual. Anyone who wants other settings, or none of
this, can run their own bridge and room supervisor. The contract is
docs/design/INTEGRATION_PATHS.md; in short:
{
"id": "night-bot", "kind": "direct", "environment": "production",
"display_name": "Fight Night bot",
"discord_lookup": true,
"lookup_sites": ["challonge", "startgg"],
"disputes": "restart",
"results_url": "https://bot.example/ember/results",
"rooms": { "max_open": 8 }
}-
discord_lookup:POST /v1/players/lookupwith{"discord": [...]}(at most 32 Discord user IDs) answers the players connected to them, with their Ember ID andparticipant_id, and links them on the connection the way BluMint's lookup does. A link a sign-in approved that the player removes stays removed: no account the player has connected (Discord's or a sign-in site's) makes it again on that connection, until the player signs in again with one of them, which gives that account's consent back. Removing a link made from a code withdraws nothing, as before. -
lookup_sites: the sign-in sites (by ID) whose accounts the connection may find players by. The same lookup takes{"accounts": [{"site": "...", "id": "..."}]}, eachida username or user ID as that site'slookup_bysays (Challonge usernames without case, start.gg user IDs exactly), and answers them inaccounts(site,id,user_id,ember_id,participant_id,connected_at), left out when empty. Anidthe site's accounts cannot have finds no one, as a Discord ID that is no snowflake does, and the rest of the lookup still answers. Each list, and each site, needs its own setting; together they name at most 32 accounts. -
disputes:"review"(the default) or"restart", which cancels a match that would wait inneeds_review. It is recorded with the connection the first time it is seen and never changes; use a new connection to change it. Where disputes restart, a game only one player reported as aborted or called off is settled after 20 seconds instead of a minute. -
results_url: each finished match is POSTed there asio.ember.tournament.match.result.v1(outcomecompletedwith the scores and winner,restartfor a cancelled match, orexpiredfor a match nobody played, which is not a request to restart), signed exactly like a webhook with the connection's result secret,webhook-idres_<match id>.ember-bridge result-secretmakes a secret, which the operator gives the platform and stores withset-integration-secret.sh result <connection-id>. Answer 2xx once stored, or 409 if you have it already. 429 and server failures are retried for a day; other 4xx answers refuse the result for good. Once sending starts a result can no longer be corrected. The URL follows the same rules as a webhook destination. -
rooms: with the provider credential,POST /v1/roomsopens a public room for a player linked on the connection (creator: theirparticipant_idand Ember ID), who goes in first and moderates it. It counts as their one open room, and the connection has at mostmax_openopen.GET /v1/roomslists the connection's open rooms,GET /v1/rooms/{id}reads one, andPOST /v1/rooms/{id}/closecloses one. Each answer carries the room'sstate(waiting,open,closed) andjoin_url(https://embernetplay.link/r#<bridge id>/<room id>), which opens the room in Ember. That page goes live with the embernetplay.link update for the 1.1 release; until then useember://room/open?bridge=<id>&room=<id>or Ember's Paste room link. A creator who already has a room the connection opened is refusedroom_limitwith that room's ID indetails.room_id. The connection's stream carriesroom.created,room.opened,room.changedandroom.closed(withreasonclosed_by_connectionorended) for its rooms only.
A bridge with these lists players.lookup and results.signed in its
capabilities features, and rooms.connections when public rooms are on.
BluMint's connection kind has the first three settings built in.
A lobby is a queue on your connection that plays one first-to-N set after
another, for a stream or a community night. Each set is an ordinary match, so
the organizer records its games exactly as above and every match event still
fires. Lobbies go beyond the EMBER-TB-001 package; "lobbies" in the
capabilities features and lobby_rotations say a bridge has them.
-
POST /v1/lobbies(provider,Idempotency-Key) takesexternal_lobby_id,games_to_win(1 to 10),rotationandrequired_build_id, with optional displaymetadatasuch astitle. -
POST /v1/lobbies/{id}/queueadds a linked player who asked to play:{"participant_id": "...", "ember_id": "..."}. Nobody is queued without asking, and a player can be in one open lobby per connection. -
When two players are available the next set starts at once. When a set completes the seats rotate in the same transaction:
-
winner_stays(king of the hill): the loser goes to the back of the queue. -
loser_stays: the winner goes to the back. -
both_rotate: both go to the back, winner first.
The next queued players sit down; with nobody waiting the same two play a new set. A queued player with another active match on your connection keeps their place and is skipped until it ends. A seated player given another match while waiting for an opponent keeps the seat, and the set waits for that match. When such a match completes or is cancelled the lobby picks up where it left off, with a
lobby.queue.changedevent whose reason isplayer_available. -
-
POST /v1/lobbies/{id}/queue/{participant_id}/leavetakes a player out. Leaving a seat cancels the running set; the other player stays seated. A player whose link ends leaves the same way, with the reasonidentity_unlinked. -
POST /v1/lobbies/{id}/close(provider or organizer, with a reason and the lobby revision) cancels the running set and empties the lobby. -
GET /v1/lobbies/{id}shows the seats, the queue in order, the running set, the current streak (who has won sets back to back, and how many) and the lobby'sstandings: everyone who has finished a set there, ranked by sets won, then fewest lost, then game difference, then best streak.
A lobby set cannot be cancelled through /v1/matches/{id}/cancel, and a
correction that would reopen a finished lobby set is refused, because the
lobby has already moved on. external_match_id values starting with lobby:
are reserved for lobby sets.
Lobby events are lobby.created, lobby.queue.changed, lobby.set.started,
lobby.set.completed (winner, loser, scores, streak and the queue after the
rotation) and lobby.closed. Players who have joined a lobby can read its
events with their own session; those events name players by Ember ID only,
and each set's match events stay visible to that set's two players.
The game's own rooms follow the same set lengths and rotations, but today they run on their own: a room table does not report to a bridge lobby yet.
A tournament is a bracket the bridge runs for you: players register, you start it, and every set is created as a match the moment both of its players are known and free. Results are entered game by game like any other match, and the bracket moves on in the same step.
-
POST /v1/tournaments(provider,Idempotency-Key) takesexternal_tournament_id,format(single_elimination,double_eliminationorround_robin),games_to_win, and optionallyfinals_games_to_win(the final, or the grand final and its reset),grand_final_reset(double elimination, on by default),required_build_idand displaymetadatasuch astitle. -
POST /v1/tournaments/{id}/entrantsregisters a linked player who asked to enter, until the start. Elimination brackets take up to 128 players, a round robin up to 32. -
POST /v1/tournaments/{id}/start(provider or organizer, with the tournament revision) seeds the players and starts the first sets. Passseedingwith every registeredparticipant_id, top seed first, or leave it out for registration order. Brackets use standard seeding, so the top seeds get the byes when the count is not a power of two. -
POST /v1/tournaments/{id}/entrants/{participant_id}/withdraw(provider or organizer) takes a player out. Before the start that is all; once running, a set they are playing is cancelled and every set they have left goes to their opponent as a walkover. A player whose link ends is withdrawn the same way. -
POST /v1/tournaments/{id}/cancel(with a reason and the revision) cancels every running set. -
GET /v1/tournaments/{id}shows the entrants with their seed and, at the end, their placement; every set with its label (for example "Winners semifinals" or "Grand final"), status, players, match and score; and for a round robin the standings.
Each set's match carries round_label and tournament_node in its metadata,
plus your tournament metadata, so match.created and match.completed read
like any other match. A set whose player is still busy in another match on
your connection waits and starts as soon as that match ends. A lobby does not
seat a player whose bracket set is waiting for them: the bracket comes first.
Placements follow how far each player got, so players who go out at the same stage share a place (two 3rds in single elimination, for example). A round robin ranks by sets won, then game difference, then games won, then the head-to-head result when exactly two players are level, then seed; a walkover counts as a set won.
To fix a wrong result, void the game as usual. When that leaves nobody with
enough wins, the set reopens and takes back what its result fed: later sets that have no games yet are cleared and
their matches cancelled, and are created again once the set is decided. A
correction is refused once a later set it fed has a game recorded, and once
the tournament is over. Bracket sets cannot be cancelled through
/v1/matches/{id}/cancel, and external_match_id values starting with
tournament: are reserved.
Tournament events are tournament.created, tournament.entrants.changed,
tournament.started, tournament.match.started, tournament.match.completed
(who advanced where, and who was eliminated), tournament.match.reopened,
tournament.completed (the placements) and tournament.cancelled. Entrants
can read a tournament and its events with their own session. Tournament events
name players by Ember ID only, a player reading the snapshot sees only their
own participant_id, and each set's match events stay visible to that set's
two players. A correction is refused for a set whose player has withdrawn.
GET /v1/players/{ember_id}/record adds up a player's finished matches: sets
played, won and lost, games won, lost and drawn, cancelled matches, and the
most recent matches (?limit=, 10 by default, at most 50) with the opponent,
the score, round_label and the lobby a set belonged to. A provider sees the
matches on its connection, an organizer every match in its tenant, and a player
only their own record, without other players' account IDs.
Records are worked out from the accepted games each time they are read, so a
voided game or other correction shows straight away. A set a platform asked
for again from a score (BluMint's startingWins) counts that score in the
set's result and its recent score, but only the games played in it in games
won and lost. Lobby standings work the same way; only a lobby player's best
streak is kept as sets are played.
Events are CloudEvents 1.0 with an emberseq cursor, for example
io.ember.tournament.match.completed.v1. Read them in any of three ways:
-
Webhooks.
POST /v1/webhook-subscriptionswith anhttpsURL and the event types you want. The response holds the signing secret once. Each delivery carrieswebhook-id,webhook-timestampandwebhook-signature(Standard Webhooks, HMAC-SHA256 overid.timestamp.body). Verify against the raw body before parsing, reject timestamps more than five minutes off, deduplicate on the event ID, and answer 2xx only once the event is stored. Failed deliveries are retried for up to 24 hours.rotate-secretsigns with both secrets for an overlap period you choose. -
Polling.
GET /v1/events?after=<cursor>returns events after a cursor. -
SSE.
GET /v1/events/stream, resuming withLast-Event-ID.
Providers see their connection's events, organizers their tenant's match events, and players their own matches, links, lobbies and tournaments.
server/ember/notifier is a ready-made subscriber that posts news to a
Discord channel and to Twitch chat. With a bot section it is also a room
bot: /room in Discord and !room in Twitch chat open a public Ember room
and answer with its link. Configure it with a JSON file:
{
"listen": "127.0.0.1:8790",
"bridge_origin": "http://127.0.0.1:8787",
"secrets": ["env:EMBER_WEBHOOK_SECRET"],
"database": "notifier.sqlite3",
"discord": {
"webhook_url": "env:DISCORD_WEBHOOK_URL",
"interactions": {
"application_id": "your application id",
"public_key": "64 hex characters from the Discord developer portal",
"bot_token": "env:DISCORD_BOT_TOKEN"
}
},
"twitch": {
"client_id": "your-app-client-id",
"token": "env:TWITCH_TOKEN",
"broadcaster_id": "channel user id",
"sender_id": "bot user id",
"room_creator": { "participant_id": "par_...", "ember_id": "emb1_..." }
},
"bot": {
"bridge_api": "https://bridge.example",
"credential": "env:EMBER_PROVIDER_CREDENTIAL",
"build_id": "the build rooms are created for",
"capacity": 8,
"room_name": "Fight Night"
},
"names": { "emb1_...": "Player name" }
}discord, twitch and bot are each optional. Without discord.webhook_url
nothing is posted to a channel and only /room is served.
Subscribe it to match.created, match.score.changed, match.completed,
match.cancelled, match.expired (announced as "Match expired, not played."),
match.needs_review and match.corrected, for lobbies
lobby.created, lobby.set.completed and lobby.closed, for tournaments
tournament.created, tournament.started, tournament.match.completed,
tournament.completed and tournament.cancelled, and for rooms a connection
opened room.created, room.opened and room.closed, then run
cargo run -p ember-notifier -- serve notifier.json. Room news names the room,
how full it is and its link (room.closed says how it ended and has no link).
The Twitch token is a user access token for the sender account; its scopes
are listed under "Twitch !room" below. Discord posts never mention anyone.
Players show by name when names lists them, otherwise by their short
fingerprint.
bot is the provider credential of a bridge connection that has rooms and
discord_lookup in its settings (see docs/design/INTEGRATION_PATHS.md):
-
bridge_api: the bridge's origin. -
credential: the provider credential, orenv:NAME. -
build_id: the build rooms are created for. -
capacity: players per room when a command gives none (default 8). -
room_name: the name when a command gives none (default "Ember room").
The bot finds a person by Discord ID (POST /v1/players/lookup), opens the
room for them (POST /v1/rooms) and reads one back (GET /v1/rooms/{id}).
Someone who already has a room the connection opened gets that room's link
again instead of a refusal.
Set the application's Interactions Endpoint URL to
https://<your host>/discord/interactions, which the notifier serves on its
listen address (put it behind HTTPS). discord.interactions takes the
application's application_id and public_key; every request is checked
against the key (Ed25519 over the timestamp and the body) and refused with
401 when it does not match. ember-notifier discord-register notifier.json
registers the command, using bot_token (or env:NAME); with guild_id set
it registers for that server only, which Discord applies at once.
api_base (default https://discord.com/api/v10) is for tests.
/room takes an optional name and capacity. Discord allows three seconds
for an answer, so the notifier answers at once with a deferred reply only the
person who ran the command sees, then looks them up and edits that reply when
the bridge has answered. A slow bridge does not cost the interaction; Discord
keeps the reply editable for fifteen minutes:
- Found: a room is opened for them and its link is posted to the channel for everyone to see. Their own reply becomes a short note that the room is open.
- Not connected: only they see a reply that sends them to
https://embernetplay.link/start#<bridge id>to connect Discord in Ember. - Any other refusal (unsupported build, a name the rules refuse, the bridge not reachable): a short plain message, visible only to them.
Opening a room is given up to 45 seconds, since the bridge may need that long to start a host.
With twitch.room_creator (the streamer's linked player on the connection)
and bot set, the notifier opens an EventSub WebSocket session
(eventsub_url, default wss://eventsub.wss.twitch.tv/ws) and subscribes to
channel.chat.message for broadcaster_id, read as sender_id. No public
endpoint is needed. A !room message from the broadcaster or a moderator
opens a room for the creator and posts its link in chat as the sender; a
second !room while the room is open posts the same link. Messages from
anyone else are ignored. The session follows Twitch's reconnect requests and
reconnects with a growing delay after a drop or a silence longer than the
keepalive timeout.
The token is the sender account's user access token (EventSub WebSocket
subscriptions need a user token whose user is the subscription's user_id).
Twitch's reference for channel.chat.message asks for these scopes:
-
user:read:chat, to read chat. -
user:write:chat, to post the news and the link.
Twitch lists user:bot and channel:bot (or the sender being a moderator in
the channel) only for subscriptions made with an app access token, which this
notifier does not use. The reference at
https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types/ is the
authority if that changes. A subscription Twitch refuses (a missing scope
shows as status 401 or 403) is logged by status and retried with the same
delay as a dropped session.
sdk/typescript wraps the API for platform developers and includes
verifyWebhook and parseEvent. See its README.
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