> ## Documentation Index
> Fetch the complete documentation index at: https://docs.joinbankroll.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Matchmaking

> Three server methods for asynchronous head-to-head matches, with recoverable tickets and atomic cancellation.

Matchmaking pairs two different players within your app. Your app owns the game,
results, payments, and resolution; none of those are matchmaking inputs.

## Setup

Use this client on your server. Your exact origin must be [Bankroll
Verified](/build/verified) and serve a signed manifest containing your
[`appKey`](/build/manifest#app-key-and-notifications). No matchmaking capability is needed.

```ts theme={null}
import { createMatchmaking } from "@joinbankroll/sdk/matchmaking";

const matchmaking = createMatchmaking<{ target: number }>({
  origin: "https://acme.example",
});
```

| Option   | Meaning                                                                                                                                                                                                                          |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `origin` | Required canonical public HTTPS origin; no path or trailing slash. It identifies your app, not the Bankroll API.                                                                                                                 |
| `key`    | Optional base58 64-byte Ed25519 secret. Defaults to `BANKROLL_APP_KEY`, then legacy `BANKROLL_PUSH_KEY`. An empty or malformed selected key fails.                                                                               |
| `apiUrl` | Optional API origin. Defaults to `BANKROLL_API_URL`, then `https://api.joinbankroll.com`. Use `https://api-s.joinbankroll.com` for staging; it requires staging verification. HTTPS, or loopback HTTP for local API development. |

Take `player` from a verified session on your server. App authentication does
not authenticate a player field submitted by a browser. Authorize each user's
access before returning tickets: app credentials can discover the whole app.

## Model

| Term          | Meaning                                                                                                                                   |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Queue**     | An app-composed opaque key plus a fixed match size and optional rating policy. Keys and tickets are isolated to your app.                 |
| **Ticket**    | One entry, addressed by a caller-chosen ID unique across all of your app's queues. Never reuse an ID.                                     |
| **Admission** | The original input, admission time, and accepted game conditions. `input.payload` is the proposal; `payload` is what the player must use. |
| **Match**     | A final pair of admissions, sharing one ID and payload. The waiting ticket's conditions win; both participants see both admissions.       |

Payloads contain shared game conditions, such as a seed or target. Keep results
and private data in your app. A player may have multiple tickets, but two tickets
with the same `player` never pair.

## API

Types are exported from `@joinbankroll/sdk/matchmaking`:

```ts theme={null}
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
export interface Queue {
  key: string;
  size: 2;
  rating?: { initial: number; widenPerSecond: number; max?: number };
}
export interface TicketInput<Payload extends Json = Json> {
  id: string;
  player: string;
  queue: Queue;
  payload: Payload;
  rating?: number;
  expiresAt?: number;
}
export interface Admission<Payload extends Json = Json> {
  input: TicketInput<Payload>;
  createdAt: number;
  payload: Payload;
}
export interface Match<Payload extends Json = Json> {
  id: string;
  queue: string;
  matchedAt: number;
  payload: Payload;
  tickets: [Admission<Payload>, Admission<Payload>];
}
export type Ticket<Payload extends Json = Json> = { id: string } & (
  | { state: "waiting"; admission: Admission<Payload> }
  | { state: "matched"; admission: Admission<Payload>; match: Match<Payload> }
  | { state: "cancelled"; admission: Admission<Payload> | null;
      reason: "requested" | "expired"; cancelledAt: number }
);
export interface TicketQuery { player?: string; id?: string; cursor?: string }
export interface TicketPage<Payload extends Json = Json> {
  tickets: Ticket<Payload>[];
  nextCursor: string | null;
}
export interface Matchmaking<Payload extends Json = Json> {
  createTicket(input: TicketInput<Payload>): Promise<Ticket<Payload>>;
  listTickets(query?: TicketQuery): Promise<TicketPage<Payload>>;
  cancelTicket(id: string): Promise<Extract<Ticket<Payload>, { state: "matched" | "cancelled" }>>;
}
```

All times are epoch milliseconds. IDs and player identifiers are nonempty UTF-8
strings up to 256 bytes; queue keys allow 512 bytes. Payloads allow 16 KiB of JSON.
Numbers must be finite. Omit optional fields rather than setting `undefined`;
`Date`, `BigInt`, functions, sparse arrays, and cyclic objects are not JSON inputs.

### Create and play

Build the input from the app action you already recorded and the verified
session. Retain the original fields for retries, including any rating or cutoff.

```ts theme={null}
const ticket = await matchmaking.createTicket({
  id: round.id,
  player: session.user.wallet,
  queue: { key: "precision/v1", size: 2 },
  payload: round.conditions,
});

if (ticket.state !== "cancelled") {
  // Play with ticket.admission.payload, then save the result in your app.
  // A matched ticket may have adopted an older ticket's target.
}
```

New admissions attempt pairing with the oldest compatible waiting ticket.
Retries and listing return state; they do not attempt new pairings. Play only
after admission, using the accepted payload. Record whether the round was played
in your app so polling or crash recovery cannot replay it. A matched ticket stays
matched after either player finishes or fails to play; your app resolves the outcome.

### Guarantees

* **Idempotent creation.** Identical input returns the ticket's current state.
  JSON object key order does not matter. Changing any field for an admitted ID
  returns `ticket_conflict`, including after it becomes terminal.
* **Fixed queue policy.** The first accepted input fixes the queue's `size` and
  `rating` policy, even if that ticket is already expired. Later disagreement
  returns `queue_conflict`. Use a new key to change policy. Only size `2` is supported.
* **Final matching.** A ticket belongs to at most one match. Both tickets return
  the same match; neither returns to the pool.
* **Atomic cancellation.** `cancelTicket` returns `matched` if pairing won, or
  `cancelled` if cancellation won. It never returns `waiting`. Retrying returns
  the same terminal outcome.
* **Cancellation before creation.** Cancelling an unknown ID records a permanent
  cancellation with `admission: null`. A delayed creation for that ID returns it
  without admission, regardless of the proposed fields.
* **No default expiry.** Omit `expiresAt` to wait indefinitely. A waiting ticket
  at or past its app-selected cutoff becomes `cancelled` with reason `expired`
  when read, considered for pairing, or cancelled. It cannot match past the cutoff.
  A past cutoff on creation cancels immediately; a completed match never expires.
* **Request-driven progress.** There are no background timers or automatic
  rematches. Reads evaluate expiry; new admissions attempt matching. Your app
  decides when to poll, cancel, or resolve a non-playing opponent.
* **Discovery with admission.** Every accepted ticket is discoverable in its app
  as soon as it can match. Terminal tickets and matches remain discoverable;
  there is no deletion or ID-reuse operation.

### Ratings

A rated ticket requires a queue rating policy. `initial` must be nonnegative,
`widenPerSecond` positive, and optional `max` at least `initial`. All are finite.

At admission time, each waiting candidate's tolerance is:

```text theme={null}
tolerance = min(max, initial + widenPerSecond * waitingSeconds)
compatible = abs(joiningRating - waitingRating) <= tolerance
```

Omitting `max` leaves the band uncapped. `waitingSeconds` is the older ticket's
elapsed wait; the joining ticket contributes no wait. The oldest compatible
candidate wins. Rated tickets do not pair with unrated tickets. When neither
has a rating, pairing is FIFO among different players.

### Recover a lost ID or reply

`listTickets()` discovers all of your app's tickets, including matched and
cancelled ones. It does not require a queue, player, or ticket ID:

```ts theme={null}
let page = await matchmaking.listTickets();
for (;;) {
  for (const ticket of page.tickets) {
    // Reconcile ticket.id, ticket.admission, and any ticket.match with your app.
  }
  if (!page.nextCursor) break;
  page = await matchmaking.listTickets({ cursor: page.nextCursor });
}
```

Optional `player` and `id` filters combine. Keep the same filters on subsequent
pages and pass the cursor unchanged. Pages contain at most 25 tickets in creation
order. A traversal covers tickets present when its first page began; states are
current when each page is read. Start another traversal for later admissions or
state changes. Unknown IDs return an empty page, not a cancellation.

Cancelling an unknown ID has no player association, so find that cancellation
by ID or app-wide listing. A player filter cannot find it.

This removes the lost-ID orphan: admission and discovery are one guarantee,
including if a crash loses the response or pairing finishes before recovery.
Even a lost player ID is recoverable through app-wide listing. Using an existing
app action's ID also makes retries and reconciliation straightforward. Recovery
requires access to the same verified app identity; it cannot make an abandoned
app return or a player finish a round.

### Lifecycle and caller flow

```mermaid theme={null}
stateDiagram-v2
    [*] --> Waiting: createTicket
    [*] --> Matched: createTicket finds opponent
    [*] --> Cancelled: past cutoff or cancel unknown ID
    Waiting --> Matched: another player joins
    Waiting --> Cancelled: cancel wins or cutoff reached
    Matched --> [*]
    Cancelled --> [*]
```

Terminal records remain discoverable.

```mermaid theme={null}
flowchart TD
    A["createTicket with the same recorded input"] --> S{"Ticket state"}
    S -->|waiting| W["Play if needed; keep result in app"]
    W --> L["listTickets"]
    L --> S
    S -->|matched| M["Play if needed; resolve match in app"]
    S -->|cancelled| X["Close entry in app"]
    W -->|give up| C["cancelTicket: match or cancellation"]
    C --> S
    A -.->|crash or lost reply| R["Retry same input, or listTickets if ID lost"]
    W -.->|crash| R
    R --> S
```

### Errors

`MatchmakingError` exposes `code` and `status` (HTTP status, or `null` for a local
failure). A body-read timeout can preserve HTTP `200`; handle the error by `code`.
Calls time out after 30 seconds and are never automatically retried.

| Code               | HTTP status  | Action                                                                       |
| ------------------ | ------------ | ---------------------------------------------------------------------------- |
| `invalid_argument` | 400 or local | Correct the input.                                                           |
| `unauthenticated`  | 401 or local | Check the selected key, origin, and token validity.                          |
| `app_not_verified` | 403          | Serve the signed manifest with `appKey` for this origin and API environment. |
| `ticket_conflict`  | 409          | Reuse the original input; inspect the ticket by ID.                          |
| `queue_conflict`   | 409          | Use the queue's existing policy or choose a new queue key.                   |
| `unavailable`      | Varies       | Outcome may be committed; retry identical input or discover the ticket.      |
| `invalid_response` | Varies       | Outcome is unknown; retry identical input or discover the ticket.            |

A timeout or missing response is not proof that creation or cancellation failed.
Do not invent a replacement ID to retry the same action.

## HTTP

Other stacks use `POST https://api.joinbankroll.com/api/matchmaking` with
`Content-Type: application/json` and [app authentication](/build/app-authentication).
The successful response is the ticket or page directly. Refusals return
`{ "error": "<code>" }`; service unavailability returns `503`. `invalid_response`
is an SDK error, not an API response code.

```json theme={null}
{ "operation": "createTicket", "input": { "id": "round-42", "player": "player-7", "queue": { "key": "precision/v1", "size": 2 }, "payload": { "target": 7 } } }
```

```json theme={null}
{ "operation": "listTickets", "input": { "player": "player-7" } }
```

```json theme={null}
{ "operation": "cancelTicket", "input": { "id": "round-42" } }
```
