Dicey Table

Table Scripting (Surface A)

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.

On this page12 sections

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.

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, 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 and a ranking block, and publishing refuses it unless a script attached to that variant calls match.end( directly (ranked-variant-no-match-end). You switch it on in Edit Mode with Fully scripted in 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 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 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.

TypeScriptconcepts.match-kernel.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.

TypeScriptconcepts.match-free.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.
  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. 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.

The generated declarations follow.

VariantInfo#

Surface A — table script · interface · 3 members

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

What world.getVariant() 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, with the same three fields.

See also#

Members#

Signature Description Returns
id The variant id, or null for a game that declares no variants. string | null
rulesEnforced Whether the variant is "Fully scripted": the script enforces the rules and decides the result. boolean
ranked Whether this table plays ranked: a ranked room and a variant that offers ranked play. boolean

variantinfo.id#

TypeScript
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 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

variantinfo.rulesEnforced#

TypeScript
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 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#

TypeScript
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 is the per-match answer.

See also

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 to know who is in this game, and in which order, and from match.onForfeit to know who left.

See also#

Members#

Signature Description Returns
seat The seat this participant plays. string
team The seat's team, or null when teams are not in use. string | null
peerId The peer id seated there when the match started (a bot's starts with bot:). string
isBot Whether a bot plays this seat. boolean
forfeited Whether this participant resigned, abandoned the table past the grace period, or was voided by an AI agent. boolean

matchparticipantinfo.seat#

TypeScript
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

matchparticipantinfo.team#

TypeScript
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

matchparticipantinfo.peerId#

TypeScript
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 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.isBot#

TypeScript
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

matchparticipantinfo.forfeited#

TypeScript
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

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, match.end and match.current answer with it, and match.onFinalized 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#

Members#

Signature Description Returns
id The match id, minted by the server. string
status 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 Finalized, but the players disagreed about the result: it is scored and an admin reviews it. boolean
ranked Whether this match counts for Rankings. false means the game still plays, unranked (see unrankedReason). boolean
unrankedReason Why the match is unranked (for example "room-not-ranked" or "variant-not-ranked"), or null when it is ranked. string | null
participants Everyone playing, one entry per seat. readonly MatchParticipantInfo[]
setup 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#

TypeScript
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.status#

TypeScript
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

matchinfo.disputed#

TypeScript
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.

See also

matchinfo.ranked#

TypeScript
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 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

matchinfo.unrankedReason#

TypeScript
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

matchinfo.participants#

TypeScript
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

matchinfo.setup#

TypeScript
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 })): 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() 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

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.

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

How a match ended, as match.end 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#

MatchEndOptions#

Surface A — table script · interface · 2 members

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

Extra detail for match.end: why the game ended and the final points. Shown in match history; neither field affects the Score.

See also#

Members#

Signature Description Returns
reason Why it ended, e.g. "checkmate" or "points": at most 40 characters of lowercase letters, digits, spaces and dashes. string
scores Final points per seat, shown in the match history. Not used for scoring. Readonly<Record<string, number>>

matchendoptions.reason#

TypeScript
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

matchendoptions.scores#

TypeScript
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

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.

TypeScript
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) and what the result was (match.end); 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#

Members#

Signature Description Returns
start(options?: { seats?: readonly string[]; kernel?: RulesKernel<any, any>; setup?: unknown }) 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) Record one kernel move after your script applied it to the table. Kernel matches only. Each move is also a takeback checkpoint. void
checkpoint() 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() The moves that still count (takebacks removed), in order, for rebuilding your own state. readonly unknown[]
setup() 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) 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) 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() The current match, or null when none has started. MatchInfo | null
onForfeit A participant resigned, or left past the reconnect grace period. Forfeits rank below everyone else. ScriptDelegate<[participant: MatchParticipantInfo]>
onDrawAgreed Every player accepted a draw offer. End the match with { draw: true } or let the platform do it. ScriptDelegate<[]>
onTakeback 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 The result was recorded (or the match was voided). info.status says which. ScriptDelegate<[info: MatchInfo]>

match.start#

TypeScript
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 Optional. Makes this a kernel match: record every move with match.move.
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>, with status: "live". Read ranked and, when it is false, unrankedReason: 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, 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(); 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. The kernel-free match.start() is in 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

match.move#

TypeScript
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 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.

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 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.checkpoint#

TypeScript
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 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.

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 does not fire.

At most 20 calls a second, shared with match.move. A call past it is dropped and reported as a diagnostic warning; it does not throw.

See also

match.moves#

TypeScript
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 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 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.setup#

TypeScript
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(). Do it when your script loads and in match.onTakeback. A script that reloads mid-match no longer holds the setup it passed to match.start; the match still does, and this is where you read it back. The same value is on the match itself as MatchInfo.setup, 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

TypeScriptmatch.setup.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.end#

TypeScript
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 { winner }, { draw: true } or { ranks }. Every seat named must be a participant.
options MatchEndOptions Optional reason and scores, shown in match history. Neither affects the Score.

Returns

Promise<MatchInfo>. In a ranked match its status is "ending" while the other players confirm, and onFinalized 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 ends with { winner }; 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, 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

match.abort#

TypeScript
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 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 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. 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. When another human was playing, such a match is also flagged for review, so a host that keeps hitting these is visible.

Example

TypeScriptmatch.abort.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.current#

TypeScript
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 | 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 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

match.onForfeit#

TypeScript
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 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 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 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

match.onDrawAgreed#

TypeScript
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

TypeScriptmatch.onDrawAgreed.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 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.onTakeback#

TypeScript
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.

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 calls its rebuild(); 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 set to false nobody can ask, which suits a game with irreversible hidden draws.

See also

match.onFinalized#

TypeScript
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 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 clears its position and logs the outcome; 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

Did this page answer your question?