# Matches and Variants

> The match global that starts a match, records its moves and reports the result, plus world.getVariant for branching one script across variants.

- Source: https://diceytable.com/docs/scripting-api/matches
- Last verified: 2026-10-04

A **match** is one game of a fully scripted variant, from the moment your script says it starts to the moment it
reports how it ended. The `match` global is the whole contract. Your script decides two things: when a match
starts, and what the result was. The platform owns everything else: who is playing, resigning, draw offers,
players who leave, takebacks, and the [Score](https://diceytable.com/docs/mods/concepts/ranked-games).

The same script serves ranked and casual play. In a room that is not ranked, `match.start` still works and
resolves with `ranked: false`, so you never write two versions of a game. And because the entry script and the
scene scripts can be shared by several [variants](https://diceytable.com/docs/mods/manifest/variants), `world.getVariant()` tells a
script which one the lobby chose.

## The three facts every entry on this page depends on

**Only a fully scripted variant is ranked.** A variant offers ranked play when its manifest entry has
[`rulesEnforced: true`](https://diceytable.com/docs/mods/manifest/reference#variants) and a `ranking` block, and publishing refuses
it unless a script attached to that variant calls `match.end(` directly
([`ranked-variant-no-match-end`](https://diceytable.com/docs/mods/scanner/what-gets-rejected#ranked-variant-no-match-end)). You switch
it on in Edit Mode with **Fully scripted** in [the Variants dialog](https://diceytable.com/docs/editor/interface/topbar#the-variants-dialog).

**The host proposes; it does not decide.** Every `match` call runs on the host and is refused on any other peer.
The result your script reports is a proposal: the other players' clients confirm it (or, with a kernel, replay
it), and the server decides what is scored. [Ranked games, Rankings and Player
Trust](https://diceytable.com/docs/mods/concepts/ranked-games) explains what that does and does not guarantee.

**Participants are seats.** A script never learns which account plays a seat. `MatchInfo.participants` lists
seats, their team, the peer id that sat there at the start, and whether a bot plays it.

## With a kernel, or without

| | Kernel match | Match without a kernel |
|---|---|---|
| Start | `match.start({ kernel })` | `match.start()` |
| Record | `match.move(m)` after each move | `match.checkpoint()` when the table is at a place worth returning to |
| How the result is checked | The other players replay the moves with your kernel | The other players confirm the result they saw |
| Grade when ranked | `kernel-verified` once another player's replay agrees | `attested` |
| After a takeback | Rebuild your state from `match.moves()` | Re-read the table (the platform already restored it) |

Use a kernel when your game has one: a [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) is pure
functions over plain data, and the same kernel your bots search with is the referee here. A game whose outcome
is physical (flicked discs, rolled dice landing on a board) has no kernel and plays without one.

## A kernel match

Nim with a kernel. The position is rebuilt from `match.moves()` instead of tracked by hand, so a takeback and a
script reload both come out right without special cases.

```ts
// content/scripting-api/examples/concepts.match-kernel.ts

// Scene script for a fully scripted variant: Nim, played as a match with a
// rules kernel. The table holds stones tagged "stone", a button tagged
// "new-game" and three buttons tagged "take-1", "take-2" and "take-3". Two
// players take turns removing stones, and whoever takes the last one wins.
//
// The kernel is the referee. Every move is recorded with match.move, and in a
// ranked room the other player's client replays the record to check the
// result before it counts.

interface Pile {
  /** Stones still on the table. */
  left: number;
  /** The seat to move. */
  toPlay: string;
  /** Both seats, first mover first. */
  seats: [string, string];
}

const START_STONES = 15;

const kernel: RulesKernel<Pile, number> = {
  id: "nim-15",
  // Bump this when the rules change: a record is only replayed by the same version.
  version: 1,
  initial(seats) {
    if (seats.length !== 2) {
      throw new Error("Nim needs exactly two seats.");
    }
    return { left: START_STONES, toPlay: seats[0], seats: [seats[0], seats[1]] };
  },
  currentPlayer(pile) {
    return pile.toPlay;
  },
  legalMoves(pile) {
    return [1, 2, 3].filter((take) => take <= pile.left);
  },
  apply(pile, take) {
    const next = pile.toPlay === pile.seats[0] ? pile.seats[1] : pile.seats[0];
    return { left: pile.left - take, toPlay: next, seats: pile.seats };
  },
  terminal(pile) {
    if (pile.left > 0) {
      return null;
    }
    // Whoever took the last stone moved just before the seat now "to play".
    const winner = pile.toPlay === pile.seats[0] ? pile.seats[1] : pile.seats[0];
    return { value: { [winner]: 1, [pile.toPlay]: -1 } };
  },
  encoding: { planes: 1, height: 1, width: 1 },
  encode(pile) {
    return new Float32Array([pile.left / START_STONES]);
  },
  policySize: 3,
  moveIndex(take) {
    return take - 1;
  },
  moveAt(index) {
    return index + 1;
  }
};

// The position, rebuilt from the match's own record rather than tracked by
// hand: after a takeback or a script reload the record is the only truth.
let pile: Pile | null = null;

function rebuild(): void {
  const current = match.current();
  if (!current || current.status !== "live") {
    pile = null;
    return;
  }
  let state = kernel.initial(current.participants.map((participant) => participant.seat));
  for (const take of match.moves()) {
    state = kernel.apply(state, take as number);
  }
  pile = state;
}

function seatOf(peerId: string): string | null {
  return world.getPlayers().find((player) => player.peerId === peerId)?.seat ?? null;
}

async function newGame(): Promise<void> {
  if (match.current()?.status === "live") {
    world.broadcast("A game is already being played.");
    return;
  }
  const seats = world.getPlayers()
    .map((player) => player.seat)
    .filter((seat): seat is string => seat !== null)
    .slice(0, 2);
  if (seats.length < 2) {
    world.broadcast("Nim needs two seated players.");
    return;
  }
  const stones = await world.getAllObjects({ tag: "stone" });
  for (let index = stones.length; index < START_STONES; index += 1) {
    world.spawnObject({ kind: "token", name: "stone", position: [index * 0.15 - 1, 0.1, 0], tags: ["stone"] });
  }
  const info = await match.start({ seats, kernel });
  rebuild();
  world.broadcast(info.ranked ? "Ranked match started." : `Casual game (${info.unrankedReason ?? "unranked"}).`);
}

async function take(count: number, actor: string): Promise<void> {
  if (!pile) {
    return;
  }
  if (seatOf(actor) !== kernel.currentPlayer(pile)) {
    world.broadcast("It is not your move.");
    return;
  }
  if (!kernel.legalMoves(pile).includes(count)) {
    world.broadcast(`Only ${pile.left} stones are left.`);
    return;
  }
  const stones = await world.getAllObjects({ tag: "stone" });
  for (const stone of stones.slice(0, count)) {
    stone.destroy();
  }
  // Record the move AFTER the table shows it. Each move is also a takeback
  // checkpoint: the table as it is now is what a takeback returns to.
  match.move(count);
  pile = kernel.apply(pile, count);

  const over = kernel.terminal(pile);
  if (over) {
    const winner = Object.keys(over.value).find((seat) => over.value[seat] === 1);
    if (winner) {
      await match.end({ winner }, { reason: "last stone" });
    }
  }
}

match.onTakeback.add(({ movesRemaining }) => {
  // Everyone agreed. The platform has already put the stones back and removed
  // the move from match.moves(), so only the script's own position is stale.
  rebuild();
  world.broadcast(`Move taken back. ${movesRemaining} moves stand.`);
});

match.onForfeit.add((participant) => {
  // In a two-player game a forfeit decides the match on its own; nothing to end.
  world.broadcast(`${participant.seat} forfeits.`);
});

match.onFinalized.add((info) => {
  pile = null;
  world.log(`Match ${info.id}: ${info.status}${info.disputed ? ", disputed" : ""}.`);
});

void (async () => {
  for (const count of [1, 2, 3]) {
    for (const button of await world.getAllObjects({ kind: "button", tag: `take-${count}` })) {
      button.onPressed.add((_button, context) => {
        void take(count, context.actor);
      });
    }
  }
  for (const button of await world.getAllObjects({ kind: "button", tag: "new-game" })) {
    button.onPressed.add(() => {
      void newGame();
    });
  }
  // A script that reloads mid-match (a resume, a host change) has lost its
  // kernel. Starting again with the same kernel rebinds it to the running
  // match instead of opening a second one.
  if (match.current()?.status === "live") {
    await match.start({ kernel });
  }
  rebuild();
})();
```

## A match without a kernel

A dice race for two to six players, ended with `{ ranks }`. The score lives on the table as tokens, so a
takeback needs no bookkeeping: the platform restores the table and the script counts again.

```ts
// content/scripting-api/examples/concepts.match-free.ts

// Scene script for a fully scripted variant with no rules kernel: a dice race
// for two to six players. Each roll of 5 or more earns the roller a point
// token; the first player to 5 points ends the game, and everyone is ranked
// by points, equal points sharing a place.
//
// There is no kernel, so nobody replays this game: the other players confirm
// the result instead. The score lives ON THE TABLE (one token per point,
// tagged "point-<seat>"), which is what makes takebacks free: the platform
// restores the table, and the script only has to count again.

const TARGET = 5;

function pointTag(seat: string): string {
  return `point-${seat}`;
}

async function scores(): Promise<Record<string, number>> {
  const current = match.current();
  const out: Record<string, number> = {};
  for (const participant of current?.participants ?? []) {
    out[participant.seat] = (await world.getAllObjects({ tag: pointTag(participant.seat) })).length;
  }
  return out;
}

/** 1 for the most points; equal points share a place, and the next place is skipped. */
function ranksFrom(points: Record<string, number>): Record<string, number> {
  const ranks: Record<string, number> = {};
  for (const seat of Object.keys(points)) {
    ranks[seat] = 1 + Object.values(points).filter((other) => other > points[seat]).length;
  }
  return ranks;
}

async function newGame(): Promise<void> {
  if (match.current()?.status === "live") {
    return;
  }
  const seats = world.getPlayers()
    .map((player) => player.seat)
    .filter((seat): seat is string => seat !== null);
  for (const seat of seats) {
    for (const token of await world.getAllObjects({ tag: pointTag(seat) })) {
      token.destroy();
    }
  }
  // No kernel and no seats: every seated player and bot takes part.
  const info = await match.start();
  world.broadcast(info.ranked ? "Ranked race started." : "Casual race started.");
}

globalEvents.onDiceRollResult.add(async (roll) => {
  const current = match.current();
  if (!current || current.status !== "live" || roll.seat === null) {
    return;
  }
  const seat = roll.seat;
  if (!current.participants.some((participant) => participant.seat === seat && !participant.forfeited)) {
    return;
  }
  if (roll.total < 5) {
    return;
  }
  await world.spawnObject({ kind: "token", name: pointTag(seat), position: [0, 0.1, 0], tags: [pointTag(seat)] });
  // The table now shows the new point: mark it as a place a takeback can
  // return to. The platform adds one of its own at every turn end.
  match.checkpoint();

  const points = await scores();
  if (points[seat] >= TARGET) {
    await match.end({ ranks: ranksFrom(points) }, { reason: "points", scores: points });
  }
});

match.onTakeback.add(async () => {
  // The point tokens are already back where they were. Recount and say so.
  const points = await scores();
  world.broadcast(`Taken back. ${Object.entries(points).map(([seat, n]) => `${seat} ${n}`).join(", ")}.`);
});

match.onFinalized.add((info) => {
  world.broadcast(info.status === "finalized" ? "The race is recorded." : `The race ended: ${info.status}.`);
});

void (async () => {
  for (const button of await world.getAllObjects({ kind: "button", tag: "new-game" })) {
    button.onPressed.add(() => {
      void newGame();
    });
  }
})();
```

## When things happen

1. **`match.start`** resolves once the match is live. In a ranked room that waits for the server to confirm the
   participants (up to 10 seconds; with no answer the match plays on unranked as `server-unavailable`). In any
   other room it resolves at once with `ranked: false`.
2. **During play**, record each move (`match.move`) or checkpoint (`match.checkpoint`). The handlers below fire as
   things happen: a forfeit, an agreed draw, an agreed takeback. Keep each move small (a cell index, a short
   `{ from, to }`): one move is capped at 4 KB, the two calls together at 20 a second, and a record past 2,000
   entries or 64 KB of moves ends the match as no contest (a `setup` does not count). The details are on [`match.move`](https://diceytable.com/docs/scripting-api/matches#matchmove).
3. **`match.end`** resolves with the match in `"ending"` while the other players confirm a ranked result, or
   `"finalized"` straight away for an unranked one.
4. **`onFinalized`** fires once the result is recorded, or the match is voided, or it ended as no contest.

## What the match locks

While a **ranked** match is live, nobody may rewrite the table on their own, the host included. Undo becomes
**Ask to take back**, which every other human participant must agree to. Loading a save (Load from File and
Restore Autosave included), resetting the table and opening Edit Mode need the same agreement and end the match
with no result. Spawning from the library,
deleting, the scale tool, changing a participant's seat and adding or removing bots are blocked until it ends.
Dice, cards, flips and every ordinary move stay open. An unranked match locks nothing.

In a live **unranked** match (a game against bots, or a room that is not ranked), the host's Undo is a
takeback too, with no one to ask: it goes back to just before the last move a person made, taking any bot
replies after it with it, restores that checkpoint and fires [`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback). Undo
never rewinds the pieces behind a running match's back.

The details, including the four-declines rule, are on
[Ranked games, Rankings and Player Trust](https://diceytable.com/docs/mods/concepts/ranked-games#takebacks-need-everyone-to-agree).

The generated declarations follow.

## VariantInfo

_Surface A — table script · interface · 3 members_

The variant a table is playing: `world.getVariant()`.

What [`world.getVariant()`](https://diceytable.com/docs/scripting-api/world#worldgetvariant) answers: the variant the lobby chose for
this table, whether it is fully scripted, and whether the table plays ranked. Three read-only fields, all public.

### How, why and when to use it

Branch one script across several variants on `id`; tell players up front when `ranked` is true. The mod-script
equivalent is [`ModVariantInfo`](https://diceytable.com/docs/scripting-api/api#modvariantinfo), with the same three fields.

### See also

- [Game variants](https://diceytable.com/docs/mods/manifest/variants).

### Members

| Signature | Description | Returns |
|---|---|---|
| [`id`](https://diceytable.com/docs/scripting-api/matches#variantinfoid) | The variant id, or null for a game that declares no variants. | `string \| null` |
| [`rulesEnforced`](https://diceytable.com/docs/scripting-api/matches#variantinforulesenforced) | Whether the variant is "Fully scripted": the script enforces the rules and decides the result. | `boolean` |
| [`ranked`](https://diceytable.com/docs/scripting-api/matches#variantinforanked) | Whether this table plays ranked: a ranked room and a variant that offers ranked play. | `boolean` |

### variantinfo.id

```ts
readonly id: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The variant id, or null for a game that declares no variants.

The chosen variant's `id`, exactly as the manifest's [`variants`](https://diceytable.com/docs/mods/manifest/reference#variants) list declares it.

#### Returns

`string | null`. `null` for a game that declares no variants, and in Edit Mode's ▶ Play Scripts.

#### How, why and when to use it

Compare it with the ids you declared. It is stable: renaming a variant in the editor changes its name, never its id.

#### Gotchas

**An id your script does not know is normal.** An author can add a variant later without touching the script. Fall back to a default rather than throwing.

#### See also

- [`variants[].id`](https://diceytable.com/docs/mods/manifest/reference#variants).

### variantinfo.rulesEnforced

```ts
readonly rulesEnforced: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Whether the variant is "Fully scripted": the script enforces the rules and decides the result.

Whether the chosen variant is **Fully scripted**: the variant's script enforces the rules and decides the result. It is the variant's [`rulesEnforced`](https://diceytable.com/docs/mods/manifest/reference#variants) manifest field.

#### How, why and when to use it

Mostly a statement of intent for shared code: a helper used by a scripted and a by-hand variant can skip enforcing anything where this is `false`.

#### Gotchas

**It says nothing about which scripts run.** Script attachments are per-variant scene data; a variant that is not fully scripted may still run scripts.

#### See also

- [`variantinfo.ranked`](https://diceytable.com/docs/scripting-api/matches#variantinforanked).

### variantinfo.ranked

```ts
readonly ranked: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Whether this table plays ranked: a ranked room and a variant that offers ranked play.

Whether this table plays ranked: the room's owner turned **Ranked** on in the lobby, and the chosen variant offers ranked play.

#### How, why and when to use it

To tell players before the first move that the result counts, and to be stricter in a ranked room than a casual one (no house rules, no optional handicaps).

#### Gotchas

**One match can still be unranked.** A participant who is not signed in, or who is trust-locked, makes that match unranked while the room stays ranked. [`MatchInfo.ranked`](https://diceytable.com/docs/scripting-api/matches#matchinforanked) is the per-match answer.

#### See also

- [`MatchInfo.ranked`](https://diceytable.com/docs/scripting-api/matches#matchinforanked).

## MatchParticipantInfo

_Surface A — table script · interface · 5 members_

One participant of a match. Participants are SEATS: accounts never reach a script.

One participant of a match. Participants are **seats**: a script learns which seat plays, which team it is on, the
peer that sat there when the match started, and whether a bot plays it, but never which account.

### How, why and when to use it

Read them from [`MatchInfo.participants`](https://diceytable.com/docs/scripting-api/matches#matchinfoparticipants) to know who is in this game, and in which
order, and from [`match.onForfeit`](https://diceytable.com/docs/scripting-api/matches#matchonforfeit) to know who left.

### See also

- [`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart) — `seats` decides who takes part.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`seat`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoseat) | The seat this participant plays. | `string` |
| [`team`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoteam) | The seat's team, or null when teams are not in use. | `string \| null` |
| [`peerId`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfopeerid) | The peer id seated there when the match started (a bot's starts with `bot:`). | `string` |
| [`isBot`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoisbot) | Whether a bot plays this seat. | `boolean` |
| [`forfeited`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoforfeited) | Whether this participant resigned, abandoned the table past the grace period, or was voided by an AI agent. | `boolean` |

### matchparticipantinfo.seat

```ts
readonly seat: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The seat this participant plays.

The seat this participant plays. This is the name every match call uses: a `winner`, the keys of `ranks` and `scores`, the seats your kernel's `initial` receives.

#### How, why and when to use it

Use it as the identity of a side for the whole match. A person can drop and reconnect with a new peer id; their seat stays the same.

#### Gotchas

**Seats, not players, are ranked.** Accounts are mapped to seats by the server and never reach your script.

#### See also

- [`MatchResult`](https://diceytable.com/docs/scripting-api/matches#matchresult).

### matchparticipantinfo.team

```ts
readonly team: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The seat's team, or null when teams are not in use.

The seat's team when the match started.

#### Returns

`string | null`. `null` when teams are not in use.

#### How, why and when to use it

Display only, today. Ranking by team is configured with `ranking.sides: "teams"`, which publishing refuses until seat teams can be declared.

#### Gotchas

**Assigned at the table.** A team is set by the host for a peer, not declared in the scene, which is exactly why a teams ladder cannot be published yet.

#### See also

- [`ranking.sides`](https://diceytable.com/docs/mods/manifest/reference#variants).

### matchparticipantinfo.peerId

```ts
readonly peerId: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The peer id seated there when the match started (a bot's starts with `bot:`).

The peer id seated there when the match started. A bot's starts with `bot:`.

#### How, why and when to use it

To match a participant against [`EventContext.actor`](https://diceytable.com/docs/scripting-api/events#eventcontext) or `world.getPlayers()` while the match is being played.

#### Gotchas

**It can go stale.** A player who reconnects gets a new peer id but keeps the seat. Map an actor to a seat through `world.getPlayers()` rather than comparing with this.

#### See also

- [`matchparticipantinfo.seat`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoseat).

### matchparticipantinfo.isBot

```ts
readonly isBot: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Whether a bot plays this seat.

Whether a game bot plays this seat.

#### How, why and when to use it

To skip things only a person needs (a prompt, a hint) and to know that this side never answers a takeback request.

#### Gotchas

**Bots never agree to anything.** A takeback in a match where every other participant is a bot is refused with "Undo needs another player to agree, and bots can't".

#### See also

- [Game bots](https://diceytable.com/docs/mods/concepts/bots).

### matchparticipantinfo.forfeited

```ts
readonly forfeited: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Whether this participant resigned, abandoned the table past the grace period, or was voided by an AI agent.

Whether this participant resigned, left past the reconnect grace period, or was voided because an AI agent took over their seat.

#### How, why and when to use it

To stop giving a forfeited seat turns in a game with three or more players, and to leave it out of anything you announce.

#### Gotchas

**Forfeits rank last whatever you report.** The server ranks forfeited sides below everyone still playing, the earliest forfeit last.

#### See also

- [`match.onForfeit`](https://diceytable.com/docs/scripting-api/matches#matchonforfeit).

## MatchInfo

_Surface A — table script · interface · 7 members_

The state of the current (or last) match, as `match.start`, `match.end` and `match.current` report it.

The state of the current (or last) match: its id, where it is in its life, whether it counts for Rankings and who
is playing. [`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart), [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend) and [`match.current`](https://diceytable.com/docs/scripting-api/matches#matchcurrent) answer
with it, and [`match.onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized) hands you the final one.

### How, why and when to use it

Check `status` before recording a move, `ranked` and `unrankedReason` to tell players what kind of game this is,
and `participants` for who takes part.

### See also

- [Matches and Variants](https://diceytable.com/docs/scripting-api/matches).

### Members

| Signature | Description | Returns |
|---|---|---|
| [`id`](https://diceytable.com/docs/scripting-api/matches#matchinfoid) | The match id, minted by the server. | `string` |
| [`status`](https://diceytable.com/docs/scripting-api/matches#matchinfostatus) | Where the match is: `"opening"` while the server confirms it, `"live"` while it is played, `"ending"` while the players confirm the result, `"finalized"` once recorded, `"void"` when aborted, `"no-contest"` when it ended too early or the result could not be verified. | `"opening" \| "live" \| "ending" \| "finalized" \| "void" \| "no-contest"` |
| [`disputed`](https://diceytable.com/docs/scripting-api/matches#matchinfodisputed) | Finalized, but the players disagreed about the result: it is scored and an admin reviews it. | `boolean` |
| [`ranked`](https://diceytable.com/docs/scripting-api/matches#matchinforanked) | Whether this match counts for Rankings. `false` means the game still plays, unranked (see `unrankedReason`). | `boolean` |
| [`unrankedReason`](https://diceytable.com/docs/scripting-api/matches#matchinfounrankedreason) | Why the match is unranked (for example `"room-not-ranked"` or `"variant-not-ranked"`), or null when it is ranked. | `string \| null` |
| [`participants`](https://diceytable.com/docs/scripting-api/matches#matchinfoparticipants) | Everyone playing, one entry per seat. | `readonly MatchParticipantInfo[]` |
| [`setup`](https://diceytable.com/docs/scripting-api/matches#matchinfosetup) | The `setup` this kernel match was started with (`match.start({ kernel, setup })`), read-only. Absent for a match started without one. After a script reload, rebuild your state from it: `info = match.current(); state = kernel.initial(seats, info.setup)`, then `kernel.apply` each of `match.moves()`. Keep it HERE, never in `world.setSavedData` (saved data reaches every player, and a setup can hold both decklists and the seed). It never travels with the table: only the host's script sees it (and a new host after a host change, which recovers it with the table's autosave). | `unknown` |

### matchinfo.id

```ts
readonly id: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The match id, minted by the server.

The match id. In a ranked room it is minted by the server; in an unranked one the host makes its own, starting `local-`.

#### How, why and when to use it

For logging and for telling one match from the next at the same table.

#### Gotchas

**Only a server id has a match page.** A `local-` match is not stored anywhere.

#### See also

- [`matchinfo.ranked`](https://diceytable.com/docs/scripting-api/matches#matchinforanked).

### matchinfo.status

```ts
readonly status: "opening" | "live" | "ending" | "finalized" | "void" | "no-contest";
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Where the match is: `"opening"` while the server confirms it, `"live"` while it is played,
`"ending"` while the players confirm the result, `"finalized"` once recorded, `"void"` when
aborted, `"no-contest"` when it ended too early or the result could not be verified.

Where the match is: `"opening"` while the server confirms it, `"live"` while it is played, `"ending"` while the players confirm the result, `"finalized"` once recorded, `"void"` when aborted or ended by a table rewrite, `"no-contest"` when it was too short or its result could not be verified.

#### How, why and when to use it

Every recording call needs `"live"`. `"finalized"`, `"void"` and `"no-contest"` are final; the next `match.start` begins a new match.

#### Gotchas

**`void` and `no-contest` are both unscored**, and neither is anybody's fault in the Score. A table rewrite the players agreed to (load, reset, Edit Mode) voids the match.

#### See also

- [`match.onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized).

### matchinfo.disputed

```ts
readonly disputed: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Finalized, but the players disagreed about the result: it is scored and an admin reviews it.

Finalized, but the players disagreed about the result. It was scored anyway (the host's result, or the result a strict majority reported) and an admin reviews it.

#### How, why and when to use it

Show it if you show results. There is nothing for a script to do about it.

#### Gotchas

**Disputes cost trust on both sides.** Whoever is found on the wrong side is recorded against; see [disputes](https://diceytable.com/docs/mods/concepts/ranked-games#when-players-disagree).

#### See also

- [Ranked games, Rankings and Player Trust](https://diceytable.com/docs/mods/concepts/ranked-games).

### matchinfo.ranked

```ts
readonly ranked: boolean;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Whether this match counts for Rankings. `false` means the game still plays, unranked (see `unrankedReason`).

Whether this match counts for Rankings. `false` means the game still plays, unranked; [`unrankedReason`](https://diceytable.com/docs/scripting-api/matches#matchinfounrankedreason) says why.

#### How, why and when to use it

Branch as little as possible on it: the same rules should apply ranked or not. Use it for what you say to players.

#### Gotchas

**It is decided at the start.** A match does not become ranked part-way through, though it can be voided.

#### See also

- [`variantinfo.ranked`](https://diceytable.com/docs/scripting-api/matches#variantinforanked) — the room's answer.

### matchinfo.unrankedReason

```ts
readonly unrankedReason: string | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Why the match is unranked (for example `"room-not-ranked"` or `"variant-not-ranked"`), or null when it is ranked.

Why the match is unranked, as a short code.

#### Returns

`string | null`. `null` when the match is ranked.

#### How, why and when to use it

Show it, or log it. Common values: `room-not-ranked` and `variant-not-ranked` (a casual game), `server-unavailable` (the Rankings server did not answer in time), `host-changed` (the host left while it was opening), `anonymous`, `trust-locked`, `bots-not-ranked`, `kernel-required`, `too-few-sides`, `pair-cap`, `self-hosted-cap`.

#### Gotchas

**Treat it as open-ended.** New reasons can appear; display an unknown one as "unranked".

#### See also

- [What the lobby checks](https://diceytable.com/docs/mods/concepts/ranked-games#who-can-play-ranked).

### matchinfo.participants

```ts
readonly participants: readonly MatchParticipantInfo[];
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Everyone playing, one entry per seat.

Everyone playing, one entry per seat, in the order `match.start` was given (or the seating order).

#### How, why and when to use it

For the seats your kernel was started with, for turn order, and for who is still in (`forfeited`).

#### Gotchas

**Fixed for the match.** A player who sits down later is not added; they join the next match.

#### See also

- [`MatchParticipantInfo`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfo).

### matchinfo.setup

```ts
readonly setup?: unknown;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The `setup` this kernel match was started with (`match.start({ kernel, setup })`), read-only.
Absent for a match started without one. After a script reload, rebuild your state from it:
`info = match.current(); state = kernel.initial(seats, info.setup)`, then `kernel.apply`
each of `match.moves()`. Keep it HERE, never in `world.setSavedData` (saved data reaches every
player, and a setup can hold both decklists and the seed). It never travels with the table:
only the host's script sees it (and a new host after a host change, which recovers it with the
table's autosave).

The `setup` this kernel match was started with ([`match.start({ kernel, setup })`](https://diceytable.com/docs/scripting-api/matches#matchstart)): two decklists,
a shuffle seed, a board size. Read-only.

#### Returns

`unknown`, frozen. Absent (`undefined`) for a match started without a setup, and always in a script on a peer: the
setup never travels with the table (a peer's copy of the match says only how big it is).

#### How, why and when to use it

**After a reload, rebuild from it.** A script that reloads mid-match (a resume, a host change) has lost every
variable it held, including the setup it passed to `match.start`. The match still has it:

```text
const info = match.current();
let state = kernel.initial(info.participants.map((p) => p.seat), info.setup);
for (const move of match.moves()) state = kernel.apply(state, move);
```

That is exactly how every replay of the match starts, so your rebuilt state matches theirs.
[`match.setup()`](https://diceytable.com/docs/scripting-api/matches#matchsetup) answers the same thing as a fresh, unfrozen copy.

#### Gotchas

**Never keep the setup in `world.setSavedData`.** Saved data reaches every player, and a setup can hold both
decklists and the seed that predicts every draw. The match keeps it for you, on the host only; the other players
receive it from the Rankings server once the result is reported, to replay the match.

**Frozen.** Copy it before changing anything; better, never change it.

#### See also

- [`match.setup`](https://diceytable.com/docs/scripting-api/matches#matchsetup) — the same value, as a copy.
- [`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart) — where it is given.

## MatchResult

_Surface A — table script · type_

How a match ended: one winner (a seat, or a team when the variant ranks teams), a draw, or a
full ranking where 1 is first and equal numbers tie.

```ts
declare type MatchResult =
  | { winner: string }
  | { draw: true }
  | { ranks: Readonly<Record<string, number>> };
```

How a match ended, as [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend) takes it. Exactly one of three shapes:

| Shape | Meaning |
|---|---|
| `{ winner: "red" }` | One seat won; everyone else shares second place. |
| `{ draw: true }` | Nobody won. |
| `{ ranks: { red: 1, blue: 2, green: 2 } }` | A full order: whole numbers from 1 (first); equal numbers tie. A participant you leave out ties for last. |

### How, why and when to use it

Use `winner` for two-player and winner-takes-all games, `ranks` for anything with places. Ranks for three or more
players are scored with a multi-player system, so a second place is worth more than a last.

### See also

- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend).

## MatchEndOptions

_Surface A — table script · interface · 2 members_

Extra detail for `match.end`. Neither field affects the Score.

Extra detail for [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend): why the game ended and the final points. Shown in match history; neither
field affects the Score.

### See also

- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend).

### Members

| Signature | Description | Returns |
|---|---|---|
| [`reason`](https://diceytable.com/docs/scripting-api/matches#matchendoptionsreason) | Why it ended, e.g. `"checkmate"` or `"points"`: at most 40 characters of lowercase letters, digits, spaces and dashes. | `string` |
| [`scores`](https://diceytable.com/docs/scripting-api/matches#matchendoptionsscores) | Final points per seat, shown in the match history. Not used for scoring. | `Readonly<Record<string, number>>` |

### matchendoptions.reason

```ts
reason?: string;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Why it ended, e.g. `"checkmate"` or `"points"`: at most 40 characters of lowercase letters, digits, spaces and dashes.

Why the match ended, e.g. `"checkmate"` or `"points"`: at most 40 characters of lowercase letters, digits, spaces and dashes.

#### Returns

`string | undefined`. Optional.

#### How, why and when to use it

Give one; match history reads much better with "resigned" and "checkmate" than with nothing.

#### Gotchas

**Anything else throws** `invalid-option` from `match.end`, and the match keeps going.

#### See also

- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend).

### matchendoptions.scores

```ts
scores?: Readonly<Record<string, number>>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Final points per seat, shown in the match history. Not used for scoring.

Final points per seat, shown in the match history.

#### Returns

`Readonly<Record<string, number>> | undefined`. Optional.

#### How, why and when to use it

For games with a score sheet. The Score players see on Rankings comes only from the result, never from these points.

#### Gotchas

**Finite numbers only**, keyed by seat; anything else throws `invalid-option`.

#### See also

- [`MatchResult`](https://diceytable.com/docs/scripting-api/matches#matchresult).

## Match

_Surface A — table script · interface · 12 members_

The match a fully scripted variant plays. Your script says when a match starts and how it
ends; the platform owns everything else (who is playing, resign, draws, abandonment,
takebacks and the Score).

Host-authoritative: every call is refused on a peer. In a room that is not ranked,
`match.start` still works and resolves with `ranked: false`, so one script serves ranked and
casual play.

Reached from a script as the injected global `match`.

```ts
declare const match: Match;
```

The match singleton: start a ranked (or casual) match and report how it ended.

The `match` global: the calls a fully scripted variant uses to start a match, record its moves and report how it
ended, and the four handlers through which the platform tells your script about things only it can see (a
forfeit, an agreed draw, an agreed takeback, the recorded result).

### How, why and when to use it

Use it in the scene script of any variant whose rules your script enforces. Your script says when a match
starts ([`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart)) and what the result was ([`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend)); the platform owns who
is playing, resigning, draws, abandonment, takebacks and the Score. In a room that is not ranked every call
still works and the match simply resolves `ranked: false`, so one script serves ranked and casual play.

A variant can only be ranked if a script attached to it calls `match.end(` directly. Publishing checks that,
so reach the global as `match`, not through an alias.

### Gotchas

**Host only.** Every call is refused on a peer that is not the host. Scripts only run on the host anyway, but
a script that keeps running across a host change is told `not-host` until it is the host again.

**Not in Edit Mode.** ▶ Play Scripts has no match: `match.start` rejects with `match-unavailable`. Test a
match at a real table.

### See also

- [Matches and Variants](https://diceytable.com/docs/scripting-api/matches) — both worked examples.
- [Ranked games, Rankings and Player Trust](https://diceytable.com/docs/mods/concepts/ranked-games) — what a ranked result does and does not guarantee.
- [`world.getVariant`](https://diceytable.com/docs/scripting-api/world#worldgetvariant) — branching one script across variants.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`start(options?: { seats?: readonly string[]; kernel?: RulesKernel<any, any>; setup?: unknown })`](https://diceytable.com/docs/scripting-api/matches#matchstart) | Begin a match with the seated players (or only `seats`). With `kernel`, every move you record with `match.move` is replayed by the other players to check the result. Refused while a match is already running, with one exception: after a script reload, a `start` with the same kernel id and version as the running match rebinds the kernel and resolves the current match, so call `match.start` again when your script resumes. A different kernel is refused. | `Promise<MatchInfo>` |
| [`move(move: unknown)`](https://diceytable.com/docs/scripting-api/matches#matchmove) | Record one kernel move after your script applied it to the table. Kernel matches only. Each move is also a takeback checkpoint. | `void` |
| [`checkpoint()`](https://diceytable.com/docs/scripting-api/matches#matchcheckpoint) | Mark a takeback checkpoint: the table as it is now. For matches without a kernel. The platform also marks one at every `onTurnEnded`. | `void` |
| [`moves()`](https://diceytable.com/docs/scripting-api/matches#matchmoves) | The moves that still count (takebacks removed), in order, for rebuilding your own state. | `readonly unknown[]` |
| [`setup()`](https://diceytable.com/docs/scripting-api/matches#matchsetup) | A copy of the `setup` the current match was started with, or `undefined` when it has none (or none has started). Rebuild your state with `kernel.initial(seats, match.setup())` and then `match.moves()`. Only the host's script reads it; a script running on a peer always reads `undefined` (the setup never travels with the table). | `unknown` |
| [`end(result: MatchResult, options?: MatchEndOptions)`](https://diceytable.com/docs/scripting-api/matches#matchend) | End the match with its result. Seats in `result` must be participants. With a kernel, the host replays the moves first: a result the moves do not reach ends the match as no contest. A result backed by something the server saw (a forfeit or resignation, or `{ draw: true }` after an agreed draw) only needs moves that replay legally. | `Promise<MatchInfo>` |
| [`abort(reason: string)`](https://diceytable.com/docs/scripting-api/matches#matchabort) | Stop the match without a result. Within the variant's `minSeconds`, or with no other human playing, the server records it as void. Later, an abort counts as the host resigning: an abort is not a free escape from a losing match. | `Promise<void>` |
| [`current()`](https://diceytable.com/docs/scripting-api/matches#matchcurrent) | The current match, or null when none has started. | `MatchInfo \| null` |
| [`onForfeit`](https://diceytable.com/docs/scripting-api/matches#matchonforfeit) | A participant resigned, or left past the reconnect grace period. Forfeits rank below everyone else. | `ScriptDelegate<[participant: MatchParticipantInfo]>` |
| [`onDrawAgreed`](https://diceytable.com/docs/scripting-api/matches#matchondrawagreed) | Every player accepted a draw offer. End the match with `{ draw: true }` or let the platform do it. | `ScriptDelegate<[]>` |
| [`onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback) | Every human agreed to take back the last move. The platform has ALREADY restored the table to the previous checkpoint and removed the move from `moves()`. Rebuild your own state from `moves()` (kernel games: replay them from `kernel.initial(seats, match.setup())`). | `ScriptDelegate<[info: { movesRemaining: number }]>` |
| [`onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized) | The result was recorded (or the match was voided). `info.status` says which. | `ScriptDelegate<[info: MatchInfo]>` |

### match.start

```ts
start(options?: { seats?: readonly string[]; kernel?: RulesKernel<any, any>; setup?: unknown }): Promise<MatchInfo>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

Begin a match with the seated players (or only `seats`). With `kernel`, every move you
record with `match.move` is replayed by the other players to check the result. Refused while
a match is already running, with one exception: after a script reload, a `start` with the
same kernel id and version as the running match rebinds the kernel and resolves the current
match, so call `match.start` again when your script resumes. A different kernel is refused.

`setup` is this match's starting data for the kernel: two decklists, a shuffle seed, a board
size, the rules of the cards in play. Only with a `kernel`. It must be plain JSON (objects,
arrays, strings, finite numbers, booleans, null) of at most 256 KB. It is recorded with the match
and handed, unchanged, to `kernel.initial(seats, setup)` by every replay: the host's check at
`match.end`, each other player's replay of the result, and after a takeback. It never travels
with the table: the other players are sent only its size while the match is played, and receive
the setup itself from the Rankings server once the result is reported, to check it. So it may
hold both decklists and the seed, and its size does not slow the table down. On a resume (the
reload rule above) the setup the match was STARTED with is kept: a different `setup` passed to
the resuming call is ignored. Read it back with `match.setup()`.

Refused with `setup-too-large` when the setup is over 256 KB of JSON, and in a ranked room with
`setup-unsupported` when the Rankings server or a player's client is too old to check one:
catch it and start without a setup (an unranked or script-checked game) instead.

Begins a match with the players seated now (or only the seats you name) and resolves once it is live. Pass
your game's rules kernel to have the other players check the result by replaying the moves.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `options.seats` | `readonly string[]` | Optional. Only these seats take part, in this order. Absent: every seated player and bot. |
| `options.kernel` | [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) | Optional. Makes this a kernel match: record every move with [`match.move`](https://diceytable.com/docs/scripting-api/matches#matchmove). |
| `options.setup` | `unknown` | Optional, kernel matches only. This match's starting data (decklists, a shuffle seed, a board size, the rules of the cards in play): plain JSON of at most 256 KB, handed to `kernel.initial(seats, setup)` by every replay. |

#### Returns

`Promise<`[`MatchInfo`](https://diceytable.com/docs/scripting-api/matches#matchinfo)`>`, with `status: "live"`. Read [`ranked`](https://diceytable.com/docs/scripting-api/matches#matchinforanked) and, when it is
`false`, [`unrankedReason`](https://diceytable.com/docs/scripting-api/matches#matchinfounrankedreason): the game plays either way.

#### How, why and when to use it

Call it when the game actually begins: a "New game" button, everyone seated, the first deal done. In a ranked
room the server confirms the participants first, which can take a moment (at most 10 seconds; with no answer the
match plays on unranked as `server-unavailable`). In any other room it resolves at once.

The order of the seats is the order your kernel's `initial(seats)` receives, and so who moves first. Pass
`seats` when the order matters or when only some of the seated players are in this game.

**A game whose start differs per match passes `setup`.** A card game's decks, a shuffle seed, a chosen board size:
a kernel cannot know them, because it is cut out of your script and replayed elsewhere. Whatever you pass is
recorded with the match and handed, unchanged, to `kernel.initial(seats, setup)` by every replay: the host's check
at [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend), each other player's replay of the result, and a rebuild after a takeback.

**The setup never travels with the table.** The host keeps it; the other players' copies of the table say only
how big it is. When the result is reported, the Rankings server hands the setup to the other players so they can
replay the match, and a new host after a host change recovers it with the table's autosave. So it may hold both
decklists and the seed, and a large setup (a card game carrying the rules of every card in both decks) does not
slow the table down. Read it back with [`match.setup()`](https://diceytable.com/docs/scripting-api/matches#matchsetup); the example there builds a seeded shuffle
from it.

**Call it again when your script resumes.** A script that reloads mid-match (a resume, a host change) has lost
its kernel object. A `start` with the same kernel id and version as the running match rebinds the kernel and
resolves the current match instead of opening a second one; `seats` is ignored then, and so is `setup`: the match
keeps the setup it was STARTED with, which is what every replay uses.

#### Example

The kernel example on the Matches page calls `match.start({ seats, kernel })` from a button, and again on load to
rebind after a reload: [A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match). The kernel-free `match.start()` is in [A match without a kernel](https://diceytable.com/docs/scripting-api/matches#a-match-without-a-kernel). There is
deliberately no third copy here.

#### Gotchas

**Refused while a match is running**, with `match-already-running`, except for the rebind above. A different
kernel, or a start from a script that still holds its kernel, is refused. End or abort the running match first.

**Refused with nobody seated** (`no-participants`), and with a `kernel` that is not a rules kernel (a
`TypeError`).

**`setup` must be plain JSON** (objects, arrays, strings, finite numbers, booleans, null), and needs a `kernel`.
Anything else is refused (`invalid-option`) before the match opens: a `Map`, a `Date` or a NaN would come out of
the record's JSON as something else, and a replay would start from a different position.

**`setup-too-large`**: the setup is over 256 KB of JSON. Send only what a replay needs: the cards actually in the
two decks, not your whole card list.

**`setup-unsupported`** (ranked rooms): the Rankings server, or one player's client, is too old to check a setup.
The server's match is closed at once (void) and the start is refused, so catch it and start again without a
setup — the game still plays, it just cannot be checked from a recorded setup. A room that is not ranked never
refuses this.

**A kernel match takes `match.move`, a match without one takes `match.checkpoint`.** Starting decides which;
you cannot switch mid-match.

**`ranked: false` is not an error.** A casual room, a variant that does not offer ranked play, a player who is
not signed in, a trust lock, bots in a variant that does not rank them: each makes this match unranked and names
why. A participant who sits down after the lobby's Start is checked here, so the room can stay ranked while one
match is not.

#### See also

- [`MatchInfo`](https://diceytable.com/docs/scripting-api/matches#matchinfo) — what it resolves with.
- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend) — the other half.
- [`RulesKernel`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernel) — the kernel you pass.
- [`match.setup`](https://diceytable.com/docs/scripting-api/matches#matchsetup) — the setup you passed, read back.

### match.move

```ts
move(move: unknown): void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Record one kernel move after your script applied it to the table. Kernel matches only. Each
move is also a takeback checkpoint.

Records one move of a kernel match, after your script has applied it to the table. The move is appended to the
match record that the other players replay, and the table as it is now becomes a takeback checkpoint.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `move` | `unknown` | The move, exactly as your kernel's `apply` takes it. Plain JSON only, at most 4 KB. |

#### How, why and when to use it

Once per move, in the order the moves happen, **after** the table shows the move. The order matters twice: the
record must replay to the result you report, and the checkpoint taken here is what an agreed takeback returns
the table to. Record before you move the pieces and a takeback would restore a table that is one move behind.

Keep the move small and canonical: the kernel's own move value, never a description of the table. A cell index
(`27`), a short `{ from: 12, to: 28 }` object or a count is right; a copy of the board is not. Every byte of the
record rides along with every full snapshot of the table while the match is live, so a fat move costs all the
players bandwidth on every update, and it brings the record limits below closer.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) calls `match.move(count)` right after removing the stones, then advances its own copy of the
position with `kernel.apply`.

#### Gotchas

**Kernel matches only.** Without a kernel it throws `no-kernel`; use [`match.checkpoint`](https://diceytable.com/docs/scripting-api/matches#matchcheckpoint).

**Only while the match is live.** It throws before `match.start` has resolved and after `match.end`.

**Plain JSON only.** Objects, arrays, strings, finite numbers, booleans and `null`. A `Map`, a `Set`, a typed
array, a `Date`, a class instance or a non-finite number (`NaN`, `Infinity`) is not plain JSON: `move` throws a
`TypeError` and nothing is recorded. Convert it to plain JSON yourself first.

**One move is at most 4 KB** of UTF-8 JSON. A larger one throws a `RangeError` and does not count.

**At most 20 calls a second.** `match.move` and `match.checkpoint` share one budget of 20 calls per second. A
call past it is dropped and reported as a diagnostic warning; it does not throw in your script, so a loop that
records faster than that silently loses moves. One move per player action never comes near it.

**The host's refusals do not throw.** A move the host refuses (too large, over the rate) shows up as a diagnostic, not as
an exception, because the call returns before the host has looked at it. Watch the diagnostics while you build.

**A record has limits, and passing one ends the match as no contest.** The host ends it, with nobody's Score
moving, when:

- the record passes 2,000 entries, moves and takebacks together (`log-too-long`);
- the moves pass 64 KB (`record-too-large`); or
- the table plus the moves would pass about 180 KB (`record-too-large`), so that a full snapshot of the table
  still fits in one data-channel message. A table that is already that large on its own does not end the match;
  only the record growing past the line does.

The [`setup`](https://diceytable.com/docs/scripting-api/matches#matchstart) does not count toward the last two: it never travels with the table. Small moves keep a
long game well inside all three.

**Moves must replay.** At `match.end` the host replays the whole record with your kernel. A move your kernel
rejects, or a record that does not reach the result you report, ends the match as no contest.

#### See also

- [`match.moves`](https://diceytable.com/docs/scripting-api/matches#matchmoves) — the moves that still count.
- [`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback) — when one is taken back.

### match.checkpoint

```ts
checkpoint(): void;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Mark a takeback checkpoint: the table as it is now. For matches without a kernel. The
platform also marks one at every `onTurnEnded`.

Marks the table as it is now as a takeback checkpoint, for a match without a kernel. An agreed takeback restores
the checkpoint before the latest one.

#### How, why and when to use it

Call it whenever the table is at a place worth returning to: after a player's move has finished resolving,
after a scoring roll has been counted. The platform adds one of its own at every turn end, so a turn-based game
that only ever wants "undo the last turn" need not call it at all.

The host keeps the last 10 checkpoints, within a memory budget, so a takeback can reach up to 10 steps back, one
agreed step at a time.

#### Example

[A match without a kernel](https://diceytable.com/docs/scripting-api/matches#a-match-without-a-kernel) calls `match.checkpoint()` after spawning each point token.

#### Gotchas

**Ignored in a kernel match.** There every `match.move` is the checkpoint; an extra one would make the table and
the record disagree about how far back a takeback went.

**Only while the match is live.** It throws before `match.start` has resolved and after `match.end`.

**A checkpoint is the table, not your script's variables.** A takeback restores entities and their state; it
does not rewind your script. Keep the score on the table, or re-read it in
[`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback).

**Large tables keep fewer.** When 10 checkpoints do not fit the budget the oldest are dropped. The host publishes
how many steps it can still go back, so Undo is disabled once there is nothing left to restore.

**A new host starts with none.** Checkpoints live in the host's memory and do not survive a host change. A
takeback the host cannot restore leaves the table as it is and shows "Can't go back further on this table";
[`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback) does not fire.

**At most 20 calls a second**, shared with [`match.move`](https://diceytable.com/docs/scripting-api/matches#matchmove). A call past it is dropped and reported as
a diagnostic warning; it does not throw.

#### See also

- [`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback).
- [`match.move`](https://diceytable.com/docs/scripting-api/matches#matchmove) — the kernel-match equivalent.

### match.moves

```ts
moves(): readonly unknown[];
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The moves that still count (takebacks removed), in order, for rebuilding your own state.

The moves of the current kernel match that still count, in order, with every agreed takeback already
removed.

#### Returns

`readonly unknown[]`, a fresh copy on every call. Empty when no match is running, for a match without a kernel,
and before the first move.

#### How, why and when to use it

To rebuild your script's own picture of the game: start from `kernel.initial(seats, match.setup())` and
`kernel.apply` each move. Do it in [`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback) and when your script loads, and you never need takeback
bookkeeping of your own: the record is the one source of truth, and it survives a script reload and a host
change.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) has a `rebuild()` that does exactly this, called on load and from `onTakeback`.

#### Gotchas

**Cast the entries.** The type is `unknown[]`, because the global cannot know your kernel's move type.

**Not the full record.** The record the other players replay keeps takebacks as entries of their own; this is
the effective list after them.

#### See also

- [`match.move`](https://diceytable.com/docs/scripting-api/matches#matchmove).

### match.setup

```ts
setup(): unknown;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

A copy of the `setup` the current match was started with, or `undefined` when it has none
(or none has started). Rebuild your state with `kernel.initial(seats, match.setup())` and then
`match.moves()`. Only the host's script reads it; a script running on a peer always reads
`undefined` (the setup never travels with the table).

A copy of the `setup` the current kernel match was started with: the per-match data (decklists, a shuffle seed,
a board size) your kernel's `initial(seats, setup)` receives.

#### Returns

`unknown`, a fresh copy on every call, so changing it changes nothing. `undefined` when no match is running, for a
match started without a `setup`, and always in a script on a peer: the setup never travels with the table.

#### How, why and when to use it

To rebuild your script's picture of the game the same way every replay does: `kernel.initial(seats, match.setup())`,
then `kernel.apply` each of [`match.moves()`](https://diceytable.com/docs/scripting-api/matches#matchmoves). Do it when your script loads and in
[`match.onTakeback`](https://diceytable.com/docs/scripting-api/matches#matchontakeback). A script that reloads mid-match no longer holds the setup it passed to
[`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart); the match still does, and this is where you read it back.
The same value is on the match itself as [`MatchInfo.setup`](https://diceytable.com/docs/scripting-api/matches#matchinfosetup), so
`kernel.initial(seats, match.current().setup)` works too.

**Never keep the setup in `world.setSavedData`** to survive a reload: saved data reaches every player, and a setup
can hold both decklists and the seed that predicts every draw. The match already keeps it, hidden.

#### Example

```ts
// content/scripting-api/examples/match.setup.ts

// Scene script: a kernel whose starting position depends on THIS match - each
// player's deck and a shuffle seed - handed to match.start once, as `setup`.
//
// Every replay of the match (the host's check at match.end, the other
// players' replays of the result, a rebuild after a takeback) calls
// kernel.initial(seats, setup) with the same setup, so all of them start from
// the same shuffled decks. Self-play training passes no setup at all, so
// initial() makes one up rather than failing.

interface DuelSetup {
  /** A list of card ids per seat. */
  decks: Record<string, string[]>;
  /** The shuffle seed, chosen once by the host. */
  seed: number;
}

interface Duel {
  seats: string[];
  /** Each seat's draw pile, next card first. */
  piles: Record<string, string[]>;
  /** The seat that drew the ace, once someone has. */
  winner: string | null;
  /** Index into seats of the seat to move. */
  toMove: number;
}

/** The same seed always gives the same order. */
function shuffled(cards: readonly string[], seed: number): string[] {
  const out = [...cards];
  let state = seed >>> 0;
  for (let index = out.length - 1; index > 0; index -= 1) {
    state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
    const pick = state % (index + 1);
    const swap = out[index];
    out[index] = out[pick];
    out[pick] = swap;
  }
  return out;
}

const kernel: RulesKernel<Duel, "draw"> = {
  id: "ace-race",
  version: 1,
  initial(seats, setup) {
    const given = setup as DuelSetup | undefined;
    const piles: Record<string, string[]> = {};
    seats.forEach((seat, index) => {
      const deck = given?.decks[seat] ?? ["ace", "two", "three", "four"];
      piles[seat] = shuffled(deck, (given?.seed ?? 1) + index);
    });
    return { seats: [...seats], piles, winner: null, toMove: 0 };
  },
  currentPlayer(duel) {
    return duel.seats[duel.toMove];
  },
  legalMoves() {
    return ["draw"];
  },
  apply(duel, _move) {
    const seat = duel.seats[duel.toMove];
    const [card, ...rest] = duel.piles[seat];
    return {
      seats: duel.seats,
      piles: { ...duel.piles, [seat]: rest },
      winner: card === "ace" ? seat : duel.winner,
      toMove: (duel.toMove + 1) % duel.seats.length
    };
  },
  terminal(duel) {
    const empty = duel.seats.every((seat) => duel.piles[seat].length === 0);
    if (!duel.winner && !empty) {
      return null;
    }
    const value: Record<string, number> = {};
    for (const seat of duel.seats) {
      value[seat] = duel.winner === null ? 0 : seat === duel.winner ? 1 : -1;
    }
    return { value };
  },
  encoding: { planes: 1, height: 1, width: 1 },
  encode(duel) {
    return new Float32Array([duel.toMove]);
  },
  policySize: 1,
  moveIndex() {
    return 0;
  },
  moveAt() {
    return "draw";
  }
};

async function startDuel(seats: string[], decks: Record<string, string[]>): Promise<void> {
  // Plain JSON of at most 256 KB: it is recorded with the match. It never
  // travels with the table - the other players get it from the Rankings
  // server only once the result is reported - so both decks and the seed
  // stay secret while it is played.
  const setup: DuelSetup = { decks, seed: Math.floor(Math.random() * 2147483647) };
  await match.start({ seats, kernel, setup });
}

// The position, rebuilt from the recorded setup and moves - never from your
// own copy of the setup, which a reloaded script no longer has.
function rebuild(): Duel | null {
  const current = match.current();
  if (!current || current.status !== "live") {
    return null;
  }
  let duel = kernel.initial(current.participants.map((participant) => participant.seat), match.setup());
  for (const move of match.moves()) {
    duel = kernel.apply(duel, move as "draw");
  }
  return duel;
}

match.onTakeback.add(() => {
  world.log(`After the takeback, ${rebuild()?.seats.length ?? 0} seats are playing.`);
});

void startDuel(["north", "south"], {
  north: ["ace", "two", "three"],
  south: ["four", "ace", "five"]
});
```

#### Gotchas

**The recorded setup wins.** A resuming `match.start({ kernel, setup })` rebinds the kernel but keeps the setup the
match was started with; the new one is ignored, and this returns the original.

**Cast it.** The global cannot know your setup's type, so it is `unknown`.

**Hidden from the other players while the match is played**, on every table, because a setup can hold both
decklists and the seed. They receive it when the match is ending, to replay it. Keeping it hidden means every
player is sent their own copy of the table while such a match is live (as on a table with face-down cards), so
leave `setup` out of a game that does not need one.

**Not in the public match history.** A finished ranked match's record is shown without its setup; only an admin
reviewing a dispute sees it.

#### See also

- [`match.start`](https://diceytable.com/docs/scripting-api/matches#matchstart): where the setup is given.
- [`MatchInfo.setup`](https://diceytable.com/docs/scripting-api/matches#matchinfosetup): the same value, on the match.
- [`RulesKernel.initial`](https://diceytable.com/docs/scripting-api/bots-and-ai#ruleskernelinitial): where it is received.

### match.end

```ts
end(result: MatchResult, options?: MatchEndOptions): Promise<MatchInfo>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

End the match with its result. Seats in `result` must be participants. With a kernel, the
host replays the moves first: a result the moves do not reach ends the match as no contest.
A result backed by something the server saw (a forfeit or resignation, or `{ draw: true }`
after an agreed draw) only needs moves that replay legally.

Ends the match with its result: one winner, a draw, or a full ranking. In a ranked match this is a proposal
that the other players confirm before the server records it.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `result` | [`MatchResult`](https://diceytable.com/docs/scripting-api/matches#matchresult) | `{ winner }`, `{ draw: true }` or `{ ranks }`. Every seat named must be a participant. |
| `options` | [`MatchEndOptions`](https://diceytable.com/docs/scripting-api/matches#matchendoptions) | Optional `reason` and `scores`, shown in match history. Neither affects the Score. |

#### Returns

`Promise<`[`MatchInfo`](https://diceytable.com/docs/scripting-api/matches#matchinfo)`>`. In a ranked match its status is `"ending"` while the other players
confirm, and [`onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized) follows. In an unranked match it is already `"finalized"`.

#### How, why and when to use it

Call it the moment your rules say the game is over. With a kernel, the host first replays the whole record (moves
and takebacks): a result the moves do not reach ends the match as **no contest**, which keeps a script bug from
being scored.

The one exception is a result backed by something the server saw: a forfeit or resignation, or `{ draw: true }`
after an agreed draw. Then the moves only have to replay legally; the board need not be finished.

Write `match.end(` directly in the variant's script. Publishing a ranked variant checks that a script attached
to it does (`ranked-variant-no-match-end`), and it only recognises the call written that way.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) ends with `{ winner }`; [A match without a kernel](https://diceytable.com/docs/scripting-api/matches#a-match-without-a-kernel) ends with `{ ranks }` and passes `scores`.

#### Gotchas

**Refused for bad results.** A seat that is not a participant throws `unknown-side`; a result that is none of the
three shapes throws `invalid-result`; a `reason` that breaks its rule, or `scores` that are not finite numbers,
throws `invalid-option`.

**Forfeits are applied for you.** A player who resigned or left ranks below everyone still playing, whatever your
ranks say, and in a two-sided game a forfeit decides the match before you report anything.

**You need not end an agreed draw.** If your script has not ended the match about 1.5 seconds after
[`onDrawAgreed`](https://diceytable.com/docs/scripting-api/matches#matchondrawagreed), the platform ends it with `{ draw: true }`.

**With teams**, name teams rather than seats. Ranking teams cannot be published yet (seat teams cannot be
declared), so in practice every ranked result names seats.

#### See also

- [`MatchResult`](https://diceytable.com/docs/scripting-api/matches#matchresult) and [`MatchEndOptions`](https://diceytable.com/docs/scripting-api/matches#matchendoptions).
- [Ranked games, Rankings and Player Trust](https://diceytable.com/docs/mods/concepts/ranked-games) — what happens after: confirmation, disputes and the Score.

### match.abort

```ts
abort(reason: string): Promise<void>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `async` |
| Capability | `none` |
| Availability | `both` |

Stop the match without a result. Within the variant's `minSeconds`, or with no other human
playing, the server records it as void. Later, an abort counts as the host resigning: an abort
is not a free escape from a losing match.

Stops the current match without a result. Early in a match that is all it does: the server records it as void
and nobody's Score moves. Later, an abort from the host counts as **the host resigning**, because an abort must
not be a free way out of a match the host is losing.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `reason` | `string` | Why, in a few words. Kept on this table's copy of the match; the server records only that the script aborted. |

#### Returns

`Promise<void>`, once the match has ended on this table. In a ranked match, [`onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized)
waits for the server's verdict (below) and fires once with it, so a late abort reports the host's resignation,
not a void. If the server has not answered within about 10 seconds it fires with `status: "void"`. In an
unranked match it fires at once with `status: "void"`.

#### How, why and when to use it

When the game cannot reach a result: the table was set up wrong, a required piece is missing, your script found
itself in a state it cannot recover from. Do it **early**, as soon as you can tell, which is when it costs nobody
anything.

An unranked match simply ends void. In a ranked match, this is what the server makes of an abort:

| When | Outcome |
|---|---|
| Within the variant's [`ranking.minSeconds`](https://diceytable.com/docs/mods/manifest/reference#variants) of the start | Void. Nobody's Score moves. |
| No other human is playing (only the host, or the host and bots) | Void. |
| Later, with another human playing | **The host resigns**: every host seat forfeits as resigned, and the match is scored as it would be for any resignation. A host holding no seat cannot resign, so the match is voided and flagged for review. |

Ending the match does **not** end the game. In a Fully scripted variant the game-over window opens only for a
finished result: a match that ended with one, or the board's own [`world.setGameOver`](https://diceytable.com/docs/scripting-api/world#worldsetgameover). After an
abort your script keeps refereeing, unrecorded, which is what "play on from the board as it stands" wants. To end
the game too, call `world.setGameOver` as well.

The platform ends a match itself, as **no contest**, when its own checks fail: a result the host's replay of the
moves does not reach, or a record that grew past [its limits](https://diceytable.com/docs/scripting-api/matches#matchmove). When another human was playing, such a
match is also flagged for review, so a host that keeps hitting these is visible.

#### Example

```ts
// content/scripting-api/examples/match.abort.ts

// Scene script: refuse to play a match that started without the pieces it
// needs, rather than let a broken game reach a result.

match.onFinalized.add((info) => {
  world.log(`Match ${info.id} finished as ${info.status}.`);
});

void (async () => {
  const current = match.current();
  if (!current || current.status !== "live") {
    return;
  }
  const boards = await world.getAllObjects({ kind: "board" });
  if (boards.length === 0) {
    await match.abort("no board on the table");
    world.broadcast("This game needs a board, so the match was stopped with no result.");
  }
})();
```

#### Gotchas

**Not a way to dodge a loss.** Past the opening, an abort is the host resigning, so a script that aborts
whenever the host is losing hands the host a loss every time.

**Throws `no-match`** when no match is running.

#### See also

- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend) — ending with a result.

### match.current

```ts
current(): MatchInfo | null;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The current match, or null when none has started.

The current match, or the last one if it has ended, or `null` when none has started at this table.

#### Returns

[`MatchInfo`](https://diceytable.com/docs/scripting-api/matches#matchinfo)` | null`, read from what the host last pushed into the sandbox: synchronous, no request.

#### How, why and when to use it

To ask "is a match being played right now?" (`match.current()?.status === "live"`) before starting one, before
recording a move, or when your script loads into a game already in progress.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) checks it before starting a new game and on load, where a live match means the script reloaded
mid-match and must rebind its kernel.

#### Gotchas

**A finished match stays here** until the next `match.start`, with its final status. Check `status`, not only
for `null`.

#### See also

- [`MatchInfo.status`](https://diceytable.com/docs/scripting-api/matches#matchinfostatus).

### match.onForfeit

```ts
readonly onForfeit: ScriptDelegate<[participant: MatchParticipantInfo]>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

A participant resigned, or left past the reconnect grace period. Forfeits rank below everyone else.

Fires when a participant resigns, leaves the table for longer than the reconnect grace period, or is voided
because an AI agent took over their seat.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `participant` | [`MatchParticipantInfo`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfo) | Who forfeited, with `forfeited: true`. |

#### How, why and when to use it

To tell the table, and to keep a game with three or more players going without the player who left: skip their
turns, take their pieces off. The match continues for everyone else.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) announces the forfeit and does nothing else, because in a two-player game the forfeit decides the
match by itself.

#### Gotchas

**You do not end the match for it.** When forfeits leave one side standing the server decides the match at once,
and [`onFinalized`](https://diceytable.com/docs/scripting-api/matches#matchonfinalized) follows. With more sides left, keep playing: a forfeit ranks below
everyone still playing, whatever your result says.

**Ranked matches only.** Resigning and the reconnect grace are run by the server, so an unranked match never
fires this.

#### See also

- [`MatchParticipantInfo.forfeited`](https://diceytable.com/docs/scripting-api/matches#matchparticipantinfoforfeited).

### match.onDrawAgreed

```ts
readonly onDrawAgreed: ScriptDelegate<[]>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Every player accepted a draw offer. End the match with `{ draw: true }` or let the platform do it.

Fires when every player has accepted a draw offer made with the platform's **Offer draw** button.

#### How, why and when to use it

End the match with `match.end({ draw: true })`, or let the platform do it: if your script has not ended the match
about 1.5 seconds later, it is ended as a draw for you. Handle it when your game wants to say something, or tidy
the table, before the result goes in.

#### Example

```ts
// content/scripting-api/examples/match.onDrawAgreed.ts

// Scene script: end an agreed draw at once, with a reason that shows in the
// match history, instead of waiting for the platform to do it.

match.onDrawAgreed.add(() => {
  world.broadcast("Draw agreed.");
  match.end({ draw: true }, { reason: "agreed" }).catch((error) => {
    world.log(`Could not end the match: ${String(error)}`);
  });
});
```

#### Gotchas

**The button is the variant's choice.** **Offer draw** only appears when the variant's
[`ranking.draws`](https://diceytable.com/docs/mods/manifest/reference#variants) is on, and only in a ranked match.

**A kernel match may end drawn mid-game.** After an agreed draw `{ draw: true }` only needs moves that replay
legally; the board does not have to be finished.

#### See also

- [`match.end`](https://diceytable.com/docs/scripting-api/matches#matchend).

### match.onTakeback

```ts
readonly onTakeback: ScriptDelegate<[info: { movesRemaining: number }]>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

Every human agreed to take back the last move. The platform has ALREADY restored the table to
the previous checkpoint and removed the move from `moves()`. Rebuild your own state from
`moves()` (kernel games: replay them from `kernel.initial(seats, match.setup())`).

Fires when a move is taken back: in a ranked match, when every other human player agreed; in an unranked
match the host recorded itself (a game against bots, a room that is not ranked), when the host presses Undo.
By then the platform has already restored the table to the earlier checkpoint and removed the moves from
[`match.moves`](https://diceytable.com/docs/scripting-api/matches#matchmoves).

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `info.movesRemaining` | `number` | The moves that still count. For a match without a kernel, how many steps the checkpoints can still go back. |

#### How, why and when to use it

Rebuild your script's own state, because nothing rewinds it for you. In a kernel game, replay `match.moves()`
from `kernel.initial`. Without a kernel, re-read the table, which is already right. A script whose state is
always derived from the record or the table needs nothing else.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) calls its `rebuild()`; [A match without a kernel](https://diceytable.com/docs/scripting-api/matches#a-match-without-a-kernel) recounts the point tokens.

#### Gotchas

**Ranked: asked for. Unranked: the host's Undo.** In a ranked match a takeback is a request every other human
must agree to, mediated by the server. In an unranked match the host's Undo goes back to just before the last
move a PERSON made, so one Undo against a bot takes back the person's move and the bot's reply together, and
this fires once for the whole step. A move counts as a person's when someone acted on the table since the
previous checkpoint. With no move by a person left to take back, Undo says "Nothing to take back on this
table." and changes nothing. Only the host can undo; a player or spectator is told so.

**One step at a time.** Each agreed ranked takeback goes back one move or checkpoint. Players can ask again,
up to the 10 checkpoints the host keeps. `info.movesRemaining` is always the count after the whole step.

**An agreed takeback the host cannot restore does nothing.** After a host change (the new host holds no
checkpoints) or beyond the checkpoints the host still holds, the table and the moves stay as they are, players see
"Can't go back further on this table", and this does **not** fire. So never treat a granted request as a
rewind on its own; only this event is. The host publishes how far back it can go, so Undo is normally disabled
before anyone can ask for a step that is not there.

**The variant can turn it off.** With [`ranking.takebacks`](https://diceytable.com/docs/mods/manifest/reference#variants) set to
`false` nobody can ask, which suits a game with irreversible hidden draws.

#### See also

- [Takebacks need everyone to agree](https://diceytable.com/docs/mods/concepts/ranked-games#takebacks-need-everyone-to-agree).
- [`match.checkpoint`](https://diceytable.com/docs/scripting-api/matches#matchcheckpoint).

### match.onFinalized

```ts
readonly onFinalized: ScriptDelegate<[info: MatchInfo]>;
```

| Badge | Value |
|---|---|
| Authority | `host-only` |
| Timing | `sync` |
| Capability | `none` |
| Availability | `both` |

The result was recorded (or the match was voided). `info.status` says which.

Fires once the match is over for good: the result was recorded, or the match was voided, or it ended as no
contest. `info.status` says which.

#### Parameters

| Parameter | Type | Notes |
|---|---|---|
| `info` | [`MatchInfo`](https://diceytable.com/docs/scripting-api/matches#matchinfo) | The match as it ended, with `status` and `disputed`. |

#### How, why and when to use it

To clear your script's per-match state and get the table ready for the next game, and to tell players what
happened. It is the one handler that always fires, however a match ends.

#### Example

[A kernel match](https://diceytable.com/docs/scripting-api/matches#a-kernel-match) clears its position and logs the outcome; [A match without a kernel](https://diceytable.com/docs/scripting-api/matches#a-match-without-a-kernel) announces it.

#### Gotchas

**`disputed: true` is still finalized.** The players disagreed about the result; it was scored anyway (the
host's result, or the majority's) and an admin reviews it. Do not restart or re-report anything.

**Unranked matches finalize at once**, inside `match.end`, so this fires before `end`'s promise resolves.

**After `match.abort()` in a ranked match it waits for the server**, so it reports what the server recorded — a
void early on, the host resigning later — and fires exactly once.

#### See also

- [`MatchInfo.status`](https://diceytable.com/docs/scripting-api/matches#matchinfostatus) and [`MatchInfo.disputed`](https://diceytable.com/docs/scripting-api/matches#matchinfodisputed).
