Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Match

Match times one contest: “collect 8 mushrooms before 60 seconds run out”, or “two laps, then the finish”. The score is the coins that addCoins credits while the match plays, a Pickup’s among them. The match ends in a victory the step the score reaches scoreToWin or the last lap is in, and in a defeat the step its time limit runs out first. A rule you leave out is off: a match with no timeLimitSeconds never runs out.

Unity’s Karting Microgame splits this between an objective and a time manager, and Unreal between a racing GameMode and its GameState. Here one Match carries both, so a game configures a race rather than writing one. “Match” is the whole contest, as Unreal’s MatchState and Rocket League’s match name it; “round” stays free for a part of one, such as a set. A game that plays one round per contest builds that round as a Match.

Its props are in MatchProps. A prop that changes while the match runs applies at its next reset, and a new key mounts a new match. A callback, such as canJoin or isTeamAlly, is read as it stands each time it is asked.

Match is the matches() plugin: list matches() in the game’s plugins beside machines() from @spawnite/engine/core, which it requires, and import every name on this page from @spawnite/engine/matches. Its source ships readable in the package, as Read its source says.

The match counts simulated seconds, the fixed steps the world takes, not the wall clock. A modal that pauses, the devtools’ pause, and a headless world that nobody steps all hold its clock. Once it has ended, the match keeps its clock and its score.

A timer of the game’s own, such as a wave of enemies every 30 seconds, is a defineMachine() wait or a system on the step, as the match’s countdown is. A view that counts with useTime keeps its count on one page alone: a room, a dump and a save never see it, and a player who joins late counts from zero.

The match is a machine, MatchMachine: its states are the MatchState values, Waiting, Countdown, Playing and Ended, and its countdown is a wait. Each state is a tag, so world.queryFirst(MatchMachine.is.playing) finds a match in play. How it ended is its MatchOutcome, Victory, Defeat or Draw, and None until then. End a match with endMatch, which writes the outcome: a bare send of the machine’s VICTORY, DEFEAT or DRAW event leaves it None.

Put Match inside the scene it times. It starts when the scene opens and goes when the scene closes. In a room, it starts when the first player opens the scene, as In a room says.

A match lives on an entity. Inside an <Entity>, Match makes that entity the match, as a behaviour adds its trait to its entity. At the scene’s root, it places an entity of its own at the origin. A Match with no queue holds every player and is the scene’s only match: a second throws in development as it mounts, and spawns nothing in a published game. Several matches with a queue each run side by side in one room, as Several matches in one room says.

A race starts on a signal, counts down, and ends when the player crosses the line, or when the game says it failed. Match covers each part:

  • delayedStart: the match waits in MatchState.Waiting, its clock held, until startMatch or useMatch().start(), as Unreal’s bDelayedStart holds a match. A sling’s release or a Start button calls it.
  • countdownSeconds: seconds in MatchState.Countdown before it plays. Without delayedStart, the countdown starts when the match mounts.
  • laps: the laps that win it, each a crossing of a trigger that holds a <LapLine />, as Count laps says. In a co-op match the lap is the match’s. In a match of teams or a free-for-all, it is the racer’s, and the first to the last lap wins.
  • endMatch(step, match, { outcome, reason }) ends it. A victory or a draw ends a match in play, and a defeat one that has not ended. The match keeps the reason, a crash, a fall, a false start, and useMatch() reads it back, so a screen can say why. A match that runs out of time has the reason "time". Export a game’s own reason as a constant, as forfeitReason below is, so the system and the screen compare the same string.

A system calls startMatch(step, match) and endMatch(step, match, options), each with its step as the authority. Each returns whether it changed the match: endMatch on a match its outcome cannot end returns false and changes nothing. A system finds the match from any entity of the scene with findMatch(world, entity), or with requireMatch, which throws where there is none. Both come from @spawnite/engine/core, because the core asks which match an entity belongs to, and matches() answers. A component calls start() and restart() on what useMatch() returns, whose entity is the match.

A system ends the match, alone and in a room alike: in a game with a room the room decides it, as In a room says, and a system runs on the room unless it says otherwise. A component that ends the match, such as a Give up button, sends a command that a system answers, as End it from a button shows. The exit gate on the Trigger page wins the match from a system when a player walks in with the keys, rather than from onEnter.

A match that only the game ends, such as the exit gate’s, takes no scoreToWin and no timeLimitSeconds: a <Match /> with no props has no score to win it and no clock to lose it, so endMatch is the only way it ends. A large scoreToWin, such as 999, is not needed to keep coins from winning it.

Put <LapLine /> inside the <Entity> of a trigger, and that trigger becomes the race’s start and finish line. The trigger is one of the following:

  • A collider with trigger: true, the line a kart race uses: a box across the road along the entity’s x, no deeper along its z than it is wide.
  • A Trigger, whose sphere meets its targets. Its default meets characters alone, so a vehicle race sets targets={TargetKind.ControlledEntities}.
  • A TrackTrigger, for a TrackMover race.

A system that spawns a line lists LapLineTrait among its traits. LapLine belongs to matches(), so a scene that places one without the plugin throws as it loads. A line with no trigger, with more than one, or with a box deeper than it is wide or rotated apart from its entity throws in development, naming the fix. In a published game it counts nothing and logs the fault once. Give each line an <Entity> of its own.

Unity, Unreal, Godot and Roblox each raise a trigger’s overlap and leave the lap to the game’s own script, and their racing samples count laps through checkpoints.

A lap counts for a racer: the player behind what crossed, the one who drives the kart or whose presence it is. The room counts it, or the step of a game played alone, and the stream brings each racer’s laps to the pages. The line counts as follows:

  • A racer’s kart that crosses the line forward, the way the line’s entity faces, along its -z, counts a lap. Racers who cross on one step each count.
  • A kart that crosses backward owes a lap, and so does the player who drives it. A forward crossing pays what the kart and its driver owe, and counts a lap only where neither owes one. So a kart that noses over the line and backs out counts once, and driving the wrong way counts nothing.
  • A kart’s debt follows where it stands, whoever drives it or none. A kart handed between drivers on the line earns no lap for any of them, and the driver who backed it over owes the lap too. A kart pushed back over the line, by a driver of another match or with no one in it, owes the lap as well. A kart handed over away from the line counts its next lap for its new driver.
  • A kart set down on the line, by a spawn or a teleport, crosses nothing until it drives over the line’s middle, and a teleport across the middle crosses nothing either.
  • A race starts from past its line. Before the match plays, and until the line has had a step of play to see the karts on it, a kart on the line owes a lap while it stands behind the line’s middle, and none ahead of it. So a kart rolled back over the line in the countdown, or left on it behind the middle through a reset, drives a full lap before its first counts.
  • A lap counts only while the racer’s match plays. A reset clears each player’s owed laps, and each kart’s debt on the match’s own lines, which in a scene with one match is every line. A kart’s debt holds through a teleport, and on a line in no match it lasts until the kart crosses forward. A kart the line never saw behind it, such as one parked behind the line and clear of it, counts its first forward crossing, so put the grid past the line.
  • A line inside a match’s <Entity>, or one a match owns, counts that match’s racers alone, as a court’s other things are its members’ alone. In a scene whose matches each have a queue, a line outside them all counts a lap of each racer’s own match, as on a track several courts share.
  • On a track line, each mover that rides into the region counts, since a mover rides only forward. A mover set into the region counts too, and one set back behind it owes nothing.
  • An entity no player stands behind counts only for a co-op match with no queue, and only where something drives it, such as an NPC’s kart. A ball rolled over the line counts nothing, and a queued co-op match counts its members’ laps alone.

The line meets whatever its trigger meets, and each entity that crosses counts for the player behind it. A game that seats a character with a collider of its own in the kart narrows the trigger to the karts with a collision layer or targets, so the driver’s body does not count a second lap.

The line reads each kart where each step ends, so make it at least four times as deep as the distance the fastest racer covers in one step. A kart at 30 m/s in a room at 30 steps a second covers a metre a step, so a 4 m line holds it. A thinner line can miss a fast kart’s lap. On a trigger collider, a lap counts one step after the crossing, for every racer alike, since the collider reports what overlapped it on the step before.

A race ranks its racers by laps, and between equal laps by whose last lap ended first. The first racer to laps wins. Two who reach it on one step win by that ranking, and two whose last laps ended on the same step tie, so the match plays on until one runs ahead. At its time limit the racer ahead wins, with the reason "time". Ranked the same way, a tie plays the match’s overtime, in which the first racer ahead wins, or ends in a draw.

Each lap’s end is kept as the match’s seconds played on the step the lap counted, to the millisecond. The count starts at the race’s start, the step the match enters MatchState.Playing, as elapsedSeconds does. These are not each lap’s own time: one lap’s time is the difference between two ends, and the first lap’s is the first end. The last end is the racer’s time over the racer’s laps. A racer seated after the start counts from the start too.

  • readMatchSlots(world, match) lists each slot’s name, player, lapsCompleted, lapEndSeconds, lapsOwed and place, in a system and, through useMatch()?.slots, on a page. The place moves only as racers cross the line, not as they pass each other between crossings.
  • useMatch() reads the reading player’s own lapsCompleted, lapEndSeconds, lapsOwed and place, which is null for a page in no slot. laps is the target, the laps that win. elapsedSeconds less the last lap’s end is the lap being driven.
  • As the match ends, each of its results, and each result MatchEndedEvent carries, holds the member’s name, player, lapsCompleted, lapEndSeconds, place and outcome. The results list in place order, those who left during play among them, ranked the same way.

A place counts from 1, and equal results share one, as 1, 2, 2, 4. A free-for-all’s winner places first whatever ended the match, such as a forfeit or the game’s own endMatch. In a match of teams each driver places by the driver’s own laps, beside the side’s outcome. A match with no laps, whose members hold none, places by score.

lapsOwed above 0 means the player drove over the line backward and has not driven over it forward since, the warning a HUD shows as “Wrong way”. It counts the player’s own crossings in play: a kart’s own debt, such as one rolled back over the line in the countdown or one the player takes over from another driver, is not in it. The line alone sets it, so a racer who drives the whole circuit backward sees it only at the line. A game that knows its track’s direction keeps its own warning in a trait on the player’s entity, which every page receives unless its reach says otherwise.

A game that counts its laps by checkpoints of its own, in order, credits each lap with addMatchLap(step, match, { player }) rather than a <LapLine />. The game’s system hears each checkpoint’s TriggerEnteredEvent, as Trigger shows, and findPlayerBehind(world, other) from @spawnite/engine/core names the racer, whether other is a character or a kart. The lap carries its end time and counts toward laps as a line’s does. In a co-op match with no queue, the lap is the match’s too, and addMatchLap(step, match) with no options credits the match alone, as an NPC’s kart does. A side’s laps are its best member’s, so the call takes no { team }.

It returns whether it counted, false outside play. A player who holds no slot in the match, or no options where the laps are each racer’s own, throws in development. The lap counts toward the end on the match’s next step, so a system that anchors with before: [matches.systems.advance] has its last lap and the clock judged on one step.

A race that lets the whole field finish, rather than ending at the first racer to laps, leaves laps out. Its system credits laps with addMatchLap, stops crediting a racer who has finished, and calls endMatch once every racer has finished or a finish window runs out. The results still place by the laps it credited. useMatch()?.laps then reads 0, so the game shows its own target.

The following scene is a three-lap kart race in a room. outsideKartRace.extended.test.ts, in the room’s tests, runs it with three players whose pages each drive a kart of the predicted mechanic page’s racers() round a lane of its own, and checks every lap on the room and on each page:

packages/room/test/outside/KartRace.tsx
import {
box,
Entity,
Hud,
Panel,
PanelVariant,
Slot,
Text,
Views,
} from "@spawnite/engine";
import {
LapLine,
Match,
MatchScreen,
useMatch,
} from "@spawnite/engine/matches";
/** The finish line's centre: on the circuit's right, across the road. */
export const finishCentre = [10, 1, 0] as const;
/** The race's standings: the reading player's place, each racer by place
* with the racer's laps and the time the racer's last lap ended, and a
* warning while the reading player owes a lap for crossing the line
* backward. */
export function Standings() {
const match = useMatch();
if (!match) return null;
const rows = [...match.slots].sort(
(first, second) => first.place - second.place,
);
return (
<Hud>
<Panel slot={Slot.TopRight} variant={PanelVariant.Glass}>
{match.lapsOwed > 0 && <Text>Wrong way</Text>}
{match.place !== null && <Text>{`Place ${match.place}`}</Text>}
{rows.map((slot) => (
<Text key={slot.index}>
{`${slot.place}. ${slot.name} ${slot.lapsCompleted}/${match.laps} ${slot.lapEndSeconds.at(-1)?.toFixed(2) ?? "-"} s`}
</Text>
))}
</Panel>
</Hud>
);
}
/** A three-lap kart race, built as a creator's scene is, from the engine's
* public entries alone: a free-for-all of every player, a ground slab,
* and a finish line the karts drive through toward -z, the way its entity
* faces. The line is a trigger collider 12 m across the road and 4 m
* deep, four times what a kart at 30 m/s covers in a step, so no kart
* crosses it unseen. The first to three laps wins, every page reads its
* own laps from `useMatch()`, and the standings list the field by place.
* The kart is `outside/racer.ts`'s. */
export function KartRace() {
return (
<>
<Match freeForAll laps={3} countdownSeconds={3} />
<Entity
name="Ground"
position={[0, -0.5, 0]}
collider={{ shape: box({ size: [60, 1, 60] }) }}
/>
<Entity
name="Finish"
position={[...finishCentre]}
collider={{ shape: box({ size: [12, 2, 4] }), trigger: true }}
>
<LapLine />
<Views>
<mesh position={[0, -0.99, 0]}>
<boxGeometry args={[12, 0.02, 0.5]} />
<meshStandardMaterial color="#f4f4f4" />
</mesh>
</Views>
</Entity>
<Standings />
<MatchScreen victoryTitle="You win" defeatTitle="Race over" />
</>
);
}

List matches(), machines() and the kart’s plugin among the game’s plugins. The karts start past the line, so the first crossing completes the first lap. Standings lists the field by place from useMatch()?.slots, and the test checks that every page’s results and ended event hold the same places and lap times as the room.

The room plays the match. The scene’s <Match> spawns it on the room, the room’s steps count it down, score it and end it, and its stream brings it to each page. On a page in a room, the same <Match> spawns nothing, so useMatch() reads the match the room streams. A room runs one scene, so put the <Match> in the scene the room runs, the one package.json names under spawnite.room.scene. A page in a room whose <Match> finds no match the room streams throws in development, naming that fix, and logs it in a published game.

The room decides the match, as a Roblox server script, a Unity Netcode ServerRpc and a Godot rpc to the server leave an outcome to the server. On the room, such as in a system with no runsOn, startMatch and endMatch start and end the match every page shows. On a page in a room, useMatch()’s verbs work as follows:

  • start() sends the room the matches.start command for its match, and the room starts the match where it waits.
  • restart() sends the room the matches.restart command, and once the room’s match has ended, the room starts its scene again, so every page gets the new match. “Play again” on the match’s screen works in a room this way. The players keep their characters where they stand.
  • The room refuses a start or a restart at any other time with a MatchRefusal, which the command’s result carries to the sender’s page, and plays on. It uses not-waiting for a start of a match that does not wait, not-ended for a restart where no match has ended, and no-scene for a restart on a room whose scene is a spawn function, as a test’s.

The room steps no more while a restart mounts its scene again, as Unreal’s level load and Unity Netcode’s scene load hold the game. A command, a join or a leave that lands meanwhile waits, and the room takes it once the scene has mounted, before its next step. Two restarts in one step start the scene once. A start that names the match from before the restart is refused entity-gone, since the restart took that match away.

The room mounts its scene as it opens, before any player has the scene open, such as while every page still shows a lobby. So the room’s match waits in MatchState.Waiting until the first page opens the scene: that page’s <Match> finds the match the room streams waiting and sends matches.start for it. A restart’s new match waits the same way, and a page that has the scene open starts it. A page that opens the scene once the match plays or has ended sends nothing.

Where two pages open the scene in the same moment, both send matches.start. The room starts the match on the first and takes the second as nothing to do, with no refusal logged, since only a start from start() is refused while the match counts down or plays. A match with delayedStart waits for start() instead. Unreal’s game mode waits the same way, starting its match once the first player is in. In a game played alone, the match starts when the scene opens.

A component never ends the match itself: it sends a command, and a system that reads it calls endMatch with the reason the game chooses. The route is the same alone and in a room, so a HUD built alone runs unchanged once the game has a room, as Unreal’s GameMode and a Roblox server script end a match on a client’s request. The following plugin loses the match for every page when one player gives up:

packages/room/test/outside/forfeit.ts
import {
CommandLifetime,
defineCommand,
definePlugin,
findMatch,
readCommands,
type AuthoritativeStep,
defineSystem,
Phase,
} from "@spawnite/engine/core";
import { endMatch, MatchOutcome } from "@spawnite/engine/matches";
// A Give up button for a match, built as a creator's plugin is, from the
// engine's public entry alone: a page sends the command, and the system
// that answers it loses the match every page shows. It runs the same in
// a game played alone, which is its own room.
/** The reason the match keeps, which the match's screen reads back. */
export const forfeitReason = "gave-up";
/** Why a Give up changed nothing: the match had ended already. */
export const forfeitEnded = "ended";
/** Why a Give up changed nothing: the player's scene plays no match,
* as a lobby. */
export const forfeitNoMatch = "no-match";
/** Loses the match where a player gave up since the last step. */
function takeForfeits(step: AuthoritativeStep) {
const world = step.world;
for (const command of readCommands(step, forfeit.commands.giveUp)) {
// A player's input never throws in the room's system: a Give up
// from a scene with no match is refused.
const match = findMatch(world, command.cause.player);
if (!match) {
command.refuse(forfeitNoMatch);
continue;
}
const wasLost = endMatch(step, match, {
outcome: MatchOutcome.Defeat,
reason: forfeitReason,
});
if (wasLost) command.accept();
else command.refuse(forfeitEnded);
}
}
export const forfeit = definePlugin(() => ({
name: "forfeit",
commands: {
// The level's lifetime: a Give up sent before a restart is refused
// `level-changed`, rather than losing the new match.
giveUp: defineCommand(
{},
{
lifetime: CommandLifetime.Level,
refusals: [forfeitEnded, forfeitNoMatch],
},
),
},
// A command's reader runs on the room alone, which decides the match.
systems: [
defineSystem({
name: "takeForfeits",
phase: Phase.Rules,
answers: ["giveUp"],
run: takeForfeits,
}),
],
}));

List forfeit beside matches() in the game’s plugins. The Give up button is the engine’s Button, whose onPress calls void sendCommand(world, forfeit.commands.giveUp, {}) on the world useWorld() returns, all three names from @spawnite/engine. sendCommand returns a promise of the command’s result, which a button that shows a refusal awaits. Each page reads useMatch()?.reason as "gave-up". A Give up once the match has ended is refused ended, and one sent before a restart is refused level-changed, so it never loses the new match. One from a scene with no match, such as a lobby, is refused no-match: a player’s input never throws in the room’s system.

A game copies the file into its own source, such as src/forfeit.ts, and imports forfeit and forfeitReason from there. A test of the Give up runs the same route as the Cheer button’s sample: one player’s page presses the button, send() hands the room the player’s command, a room step answers it, and receive() brings the lost match to every page.

A hub game runs several contests in one room: courts in a park, each with its own players, score and reset, while everyone else walks around. Give each <Match> a queue, and each runs side by side. A player in no match is in the park. NBA 2K’s park runs its courts this way, as a Roblox game scripts one object per court. Unreal runs one match per world, and Photon Fusion runs one session per simulation; here the courts share one world. A match that needs a room of its own, such as a battle royale for 30, gathers its players in a lobby and moves them to a fresh room, as Teleports between rooms says.

The following scene places two beach volleyball courts behind one queue, each placed by moving its Entity. Each court’s net is solid for its players alone, and a HUD joins the line or watches a court:

packages/engine/test/outside/Courts.tsx
import {
box,
Button,
defineTextChat,
Entity,
Hud,
Panel,
Slot,
SpawnPoints,
Text,
textChannel,
useWorld,
type Position,
} from "@spawnite/engine";
import {
defineMatchQueue,
Match,
matchTextChannel,
MatchRotation,
sendMatchJoin,
sendMatchLeave,
sendMatchSpectate,
useMatch,
useMatches,
useMatchQueue,
useSpectatedMatch,
} from "@spawnite/engine/matches";
import { defineTeam } from "@spawnite/engine/teams";
// Two beach volleyball courts in one park, each a match with its own
// members, score and reset, behind one queue, as NBA 2K's park runs its
// courts. A court's sides are its own: they are not listed in teams().
// Its net is solid for its players alone, so the park walks through it,
// and a park player watches a court from where the player's
// character stands.
export const sand = defineTeam("sand");
export const sea = defineTeam("sea");
export const beach = defineMatchQueue("beach", { maxPartySize: 2 });
/** Room chat, and a channel each court's players and watchers share. */
export const textChat = defineTextChat({
channels: { all: textChannel(), court: matchTextChannel() },
});
/** One court, placed by moving its Entity: what stands under its match
* stands in the court's frame. */
export function Court({
name,
position,
}: {
name: string;
position: Position;
}) {
return (
<Entity name={name} position={position}>
<Match
name={name}
queue={beach}
teams={[sand, sea]}
teamSize={2}
scoreToWin={7}
winBy={2}
countdownSeconds={5}
rotation={MatchRotation.WinnersStay}
spectators={{ max: 8 }}
>
<SpawnPoints
team={sand}
points={[
[-4, 0, -1],
[-4, 0, 1],
]}
/>
<SpawnPoints
team={sea}
points={[
[4, 0, -1],
[4, 0, 1],
]}
/>
<SpawnPoints exit points={[[0, 0, 8]]} />
<Entity
name="Net"
membersOnly
position={[0, 1.2, 0]}
collider={{ shape: box({ size: [0.1, 2.4, 8] }) }}
/>
</Match>
</Entity>
);
}
/** The player's place: on a court, in line, watching one, or in the
* park, with a button for each. */
export function CourtHud() {
const world = useWorld();
const match = useMatch();
const watched = useSpectatedMatch();
const line = useMatchQueue(beach);
const [first] = useMatches();
const label = match
? `${match.name}: ${match.state}`
: watched
? `Watching ${watched.match.name}`
: line?.position
? `In line: ${line.position} of ${line.playerCount}`
: "In the park";
return (
<Hud>
<Panel slot={Slot.TopLeft}>
<Text>{label}</Text>
{match || watched || line?.position ? (
<Button onPress={() => void sendMatchLeave(world)}>
Leave
</Button>
) : (
<Button
onPress={() =>
void sendMatchJoin(world, { queue: beach })
}
>
Got next
</Button>
)}
{!match && !watched && first && (
<Button
onPress={() =>
void sendMatchSpectate(world, {
match: first.entity,
})
}
>
Watch {first.name}
</Button>
)}
</Panel>
</Hud>
);
}
export function Beach() {
return (
<>
<Court name="Court A" position={[0, 0, 0]} />
<Court name="Court B" position={[30, 0, 0]} />
<CourtHud />
</>
);
}

A match’s props say who competes, and its queue changes only how players join:

  • With teams, each side keeps its own score. teams={[sand, sea]} with teamSize={2} gives two sides of two, and { team, size } gives a side slots of its own, as hide and seek’s one against four.
  • With freeForAll, each member competes alone.
  • With neither, the match is co-op: one shared score and one outcome, as a solo run is.

A queued match states its slots by type: its teams, or its maxPlayers. A match with no queue has no cap and admits every player who joins the room.

A queued match’s teams are its own, so court A’s sand is nobody’s teammate or ally on court B. Leave them out of teams(), which would balance park players onto them, and declare them with defineTeam alone. A match with no queue names teams teams() lists, which teams() balances and switches as it always does. Each wrong case throws as the match mounts, naming the fix.

A page joins with sendMatchJoin(world, { queue: beach }), or { match } for one court. It resolves with the command’s result: accepted with the value { isQueued, position }, where position is 0 once the joining player holds a slot, or refused with a MatchJoinRefusal as its reason:

  • closed: setMatchOpen(step, match, false) closed the court.
  • already-in-match: the joining player holds a slot already.
  • party-too-large, not-party-leader and party-member-busy: the joining player’s party cannot join as one block, or a member holds a slot on another court, since a party never splits across two.
  • queue-full, no-match and game-rule: the queue’s maxQueued, a court or queue that is not there, and a canJoin that said no.

The queue seats first in, first seated, each party as one block: on one side in a match of teams, and in one match in a free-for-all. A block that does not fit waits while a smaller one passes, so a party of two waits for a court with two free slots: under WinnersStay on a one-against-one court, that is a court whose winner left. A system seats a player with joinMatch(step, player, { queue }), which runs the same rules, such as for a pad a player walks onto. setMatch(step, player, match, { team }) puts the player in past the queue, and setMatch(step, player, null) takes the player out.

sendMatchLeave(world) takes the player out of their slot or line. A party’s leader who leaves the line takes the leader’s whole party out of it, and any other member leaves it alone. useMatchQueue(beach) reads a line’s playerCount, the player’s position, from 1 or null where the player is not in it, and whether the player isNextUp. readMatchPlayers(world, match) lists the members. readMatchSlots lists each slot with its member’s name, score, lapsCompleted and place, the slots kept for members who dropped among them, as useMatch()?.slots does on a page.

A queued match counts down once its connected members reach minPlayers, every slot by default. With readyCheck, each also confirms with sendMatchReady(world). A leave that drops it short takes the countdown back to waiting. Its members start it, so the room refuses a page’s start() for it with has-queue; a game’s system can still call startMatch.

addMatchScore(step, match, points, { player }) scores a member, which counts for the member’s side, and { team } scores a side. A co-op match takes no options. The member is the player entity, not the character: findPlayerBehind(world, character) goes from a body to its player. A system reaches every match with world.query(MatchTrait). A competitor wins once its score reaches scoreToWin with a lead of winBy over every other. At time, the one leader wins; a tie plays overtimeSeconds, in which the first lead wins, or ends in a draw. End one yourself with endMatch(step, match, { winner }), a team, a member, or null for a draw.

maxScore caps a win-by lead: a competitor that reaches it wins whatever winBy asks, and the cap ends a match before its clock. timeUp says how the clock ends it. MatchTimeUp.End, the default, ends it on the step the clock runs out. MatchTimeUp.NextScore ends it on the next score awarded after that step, so a point whose ball was in the air at zero still counts. The lead then wins, and a score that leaves a tie plays on to the next. A volleyball set to 11, won by two, capped at 15, with a five-minute clock that ends on the next point, is scoreToWin={11} winBy={2} maxScore={15} timeLimitSeconds={300} timeUp={MatchTimeUp.NextScore}. Both are for competitors, and NextScore takes no overtimeSeconds and no laps.

A side, or a co-op match’s members together, forfeits when every connected member votes with sendMatchForfeit(world), or more than half with forfeit={MatchForfeit.Majority}. A side that empties forfeits too, unless emptyTeam={MatchEmptyTeam.Continue}. A side that forfeits is out until the reset, and the rest play on. With one side left, it wins. As it ends, the match freezes each member’s result, and useMatch()?.results reads them until it resets.

After resultSeconds, 8 by default, a queued match resets for its next contest, in the step, with no reload and no pause for the rest of the room. It destroys every entity it owns, such as a ball in flight a system spawned with spawn(step, ball, { owner: match }). It spawns its court anew, each entity under the <Match> as it stood when the match first stepped, and raises matchNumber. Its rotation decides who stays:

  • MatchRotation.AllLeave, the default: everyone leaves, to the points <SpawnPoints exit> places, and the queue seats the next match.
  • MatchRotation.WinnersStay: the winners stay; after a draw, everyone leaves.
  • MatchRotation.AllStay: everyone stays.

Each member who stays stands on the member’s side’s points, the character’s velocity stopped: points with no team in a co-op match or a free-for-all, one point a slot in turn. A rotation sends members out one by one, so under WinnersStay a party whose members played each other parts: the loser leaves and the winner stays. A match with no queue holds its end until resetMatch(step, match) or a restart. The entity a match lives on keeps its own traits through a reset, so a game resets them on MatchResetEvent. A checkpoint after any reset continues like any other.

A command a game declares with lifetime: CommandLifetime.Match ends with the contest it was sent in, refused match-changed, so a serve sent the tick before a reset never lands in the next match. The engine’s own ready and forfeit votes end the same way.

A member who drops keeps the member’s slot, team and character through the room’s hold. Past it, rejoinSeconds keeps the member’s slot by account, and the member takes it back on joining again. A kept slot counts in the results as the member’s, so under WinnersStay a winner’s stays, and a reset clears its score with everyone’s. A member or a player in line who does nothing for idleSeconds, 60 by default, leaves, as readIdleSeconds(world, player) counts it. A shared line takes the longest idleSeconds of the matches it fills. A player in line who watches a court, and an eliminated member, never go idle; standing still otherwise counts as doing nothing, so raise it for a game whose players wait in line through long matches.

Each match tells its pages with MatchStartedEvent, MatchEndedEvent, MatchJoinedEvent, MatchLeftEvent, MatchForfeitedEvent and MatchResetEvent, on the match’s entity, each with its matchNumber. MatchLeftEvent carries a MatchLeaveReason.

A match keeps its play to its members, so the park walks past a live court and a stray shot never lands on one. Each of the following reads the match an entity belongs to:

  • Bodies: an Entity marked membersOnly meets its own match’s entities, members’ characters among them, and every fixed body and platform, in the park or on another court. It passes characters and moving bodies outside its match, and other matches’ membersOnly entities. A net stays solid for its players while a passer-by walks through it, and a ball that leaves the court lands on the park’s ground.
  • Queries: a query given asEntity meets what that entity meets, so a passer-by’s ray, crosshair and camera pass a court’s net. The engine’s own shots, interact rays, camera and navmesh ask that way. A query with no asEntity meets every body, and a development world warns once when it meets a membersOnly one; asEntity: null asks room-wide on purpose.
  • Damage, chase and abilities: a hit across matches, or between a member and an entity in no match, is refused other-match, as is one from an earlier contest. A hit between two players in the park is refused outside-matches. A court’s NPC never chases the park.
  • Interact, pickups, loot, an item’s target and a conversation: an entity a match owns is its members’ alone, refused other-match, and no prompt shows for a player outside it.
  • Lap lines: a lap line a match owns counts its members’ laps alone.
  • Respawn: a member whose character respawns at the spawn comes back on the points of the member’s side, in the same match, the first no other character stands on, else the member’s own.
  • Chat: matchTextChannel() reaches a match’s members and spectators. Its canSend narrows who writes: matchTextChannel({ canSend: (world, sender) => findMatch(world, sender) !== null || "Spectators read only." }) lets members write and spectators read, since a spectator stays in the park.

matches({ damageAcrossMatches: true }) lets hits land across matches, and damageOutsideMatches: true lets the park’s players fight while the courts stay safe. Presence, nameplates, room chat, saves and other plugins’ commands stay room-wide. A system that answers a CommandLifetime.Match command and spawns with no owner warns in development, since the match’s reset would leave the entity behind: pass { owner: match }, or owner: null for an entity of no match.

A membersOnly entity needs a match above it, under a <Match> or by owner, and one with none throws in development at the first step. A page judges another player’s character by what the room streams it, so a character whose controller the room hides reads as in no match there.

A player in the park, or in a line, watches a queued match with sendMatchSpectate(world, { match }), or { match, following: player } for one member. Watching is a subscription to the match’s camera, chat and HUD, not a place: the spectator’s character stays where it stood, with its input held still by setIgnoreInput, and the spectator stays a park player for every rule above. A seat ends the spectator’s watching on the tick it seats the spectator. sendMatchSpectate(world, null) stops watching and keeps the spectator’s place in line, and sendMatchLeave ends both. A system runs the same rules with spectateMatch(step, player, target).

The spectator’s camera follows the member through setViewTarget, the next member in slot order where that one leaves, and the court, the match’s own entity, where none is left. <Match spectators={{ max, canSpectate, follow, freeCamera }}> sets the rules, up to 16 spectators following any member by default. follow takes MatchFollow.Everyone or MatchFollow.None, which keeps every spectator on the court. freeCamera: true tells the spectator’s page, through useSpectatedMatch(), that it may mount <FreeCamera>, which holds the spectator’s character still while it flies.

A refused spectate changes nothing, so a refused switch keeps the court the spectator watched. The refusals are MatchSpectateRefusal values:

  • spectators-full, game-rule and no-match: the match holds its max, canSpectate said no, or the entity is no queued match.
  • already-in-match and not-eliminated: the player plays on another court, or on this one.
  • cannot-follow: the player named is no member, or one the setting does not let the spectator follow.
  • not-spectating: a stop sent for a watch the spectator has left since.

A member is eliminated while the member’s character is down with no respawn coming, or from setMatchEliminated(step, player, true) on, as dodgeball’s hit player is out of the point. The <Match whileEliminated> prop says whom the member watches:

  • MatchFollow.OwnTeam, the default: the member’s side’s standing members, as Fortnite’s eliminated players watch their squad.
  • MatchFollow.Everyone: any standing member.
  • MatchFollow.None: the member’s own camera.

While the eliminated member watches, the member’s input is held still as a spectator’s is. The member’s character stays where it stood, a member’s, so a game that wants it off the court moves it. Elimination ends no side on its own: a game that ends a point when a side is all out reads each slot’s isEliminated and calls endMatch. A reset stands every downed member up on the member’s side and ends each elimination. readMatchSlots lists each slot’s team, player, score and isEliminated, and findTeam(world, player) reads a member’s side. useMatch() stays the member’s own match; useSpectatedMatch() reads the one the member watches, and useMatch()?.spectators lists a match’s watchers.

A match that a boss’s death wins needs one system, which reads DiedEvent. The reap, behaviours.reapDead, emits it on the boss once its health is zero: in the same step for a hit dealt before the reap, and in the next step for one dealt after it, such as a projectile’s. The boss still holds every trait it had, so the system knows it by its own mark, finds the match from it and calls endMatch. The step after that reap, the engine removes the boss.

A boss that a system spawns takes BossTrait in its spawn’s traits. A boss the scene places takes it from a prefab, since <Entity> takes no traits prop: definePrefab lists the trait, and <Entity prefab> places the boss with it. The engine’s tests run this plugin, and mount this boss and win the match with its death:

packages/engine/test/outside/bossWin.tsx
import {
definePlugin,
definePrefab,
defineTrait,
DiedEvent,
readEvents,
requireMatch,
type AuthoritativeStep,
defineSystem,
Phase,
} from "@spawnite/engine/core";
import { Entity, Health } from "@spawnite/engine";
import { endMatch, MatchOutcome } from "@spawnite/engine/matches";
/** Marks the boss whose death wins the match. */
export const BossTrait = defineTrait("outsideBoss");
export const bossWin = definePlugin(() => ({
name: "boss-win",
// No runsOn: the room alone wins a match, and a game played alone is
// its own room.
systems: [
defineSystem({
name: "win",
phase: Phase.Rules,
run: winOnBossDeath,
}),
],
}));
/** Wins the round in the step the boss dies. */
function winOnBossDeath(step: AuthoritativeStep) {
const world = step.world;
for (const { entity } of readEvents(step, DiedEvent))
if (entity?.has(BossTrait))
endMatch(step, requireMatch(world, entity), {
outcome: MatchOutcome.Victory,
});
}
/** The boss, with the mark whose death wins the match. */
export const boss = definePrefab("outsideBoss", { traits: [BossTrait] });
/** The boss a scene places, through its prefab. */
export function Boss() {
return (
<Entity prefab={boss} position={[0, 0, -30]}>
<Health maximum={500} />
</Entity>
);
}

List bossWin after matches() among the game’s plugins, and place <Boss /> in the scene.

useMatch() returns a MatchStatus for the reading player’s match: the reading player’s court’s, or the scene’s match with no queue for everyone. It returns null in the park, until the match spawns, and in a scene that mounts no <Match>. useMatch(match) reads one match, as a court’s sign does, and useMatches() lists every match. Guard the read, as the Clock below does. The status holds the following:

  • entity: the match, which startMatch and endMatch take, and its name, mode and matchNumber.
  • remainingSeconds and elapsedSeconds: the seconds left, Infinity with no limit, and the seconds played. A system reads the seconds played with readMatchElapsedSeconds(step, match), and the countdown’s with MatchMachine.read(match).secondsLeft.
  • countdownRemainingSeconds: the countdown left, its whole length while the match waits; and isOvertime.
  • maxScore, 0 for none, and timeUp: with MatchTimeUp.NextScore and remainingSeconds at 0, the next score decides the match, as a HUD’s “next point wins” says.
  • score, scoreToWin, lapsCompleted and laps: a co-op match’s shared, or the reading player’s own in a competitive one. lapEndSeconds, lapsOwed and place are the reading player’s own, as Lap times and places says.
  • state, a MatchState; outcome, a MatchOutcome, the reading player’s own; and reason.
  • players, slots, capacity, teams, isOpen and queue; and once it ends, results, in place order, and winningTeam. results is empty until the match ends.
  • The verbs start(), restart() and reset().

The score counts only what addCoins credits while the match is in MatchState.Playing:

  • A coin credited while the match waits or counts down adds nothing. The one exception: a coin a system credits between the match’s step and the machines’ step, in the step a countdown ends, counts toward the match that starts there.
  • A wallet a save restores, or a game sets on the trait itself, adds nothing, so coins carried in from an earlier run never win a match on its first step.
  • A spend, such as a shop’s spendCoins, never lowers the score.
  • In a co-op match with no queue, every wallet’s credits count toward the one match: in a room, each player’s coins add to the room’s match.
  • In a match of teams or a free-for-all, a coin counts toward the member it was credited to, and to the member’s side. readCreditedCoins(world, { player }) reads the member’s own count.
  • scoreCoins={false} turns coins off for a game that scores with addMatchScore alone, so a purchase never scores a point.

The match scores in its step, and startMatch and endMatch score before they change a match, so a coin a game’s system credited earlier in the step counts toward the match it ends and never toward one it starts. A match scores first as it spawns too, from <Match> or from a spawned MatchTrait, so it counts only the coins credited after it.

What the match scores is the rise in readCreditedCoins(world), from @spawnite/engine/core: every coin addCoins has credited on the world, a count that only rises. A game reads the same count for a scoreboard or a quest of its own.

The wallet useWallet() shows is the player’s character’s. In a game whose player has no character, such as a rider on a track, it is the wallet on the entity under the client’s authority.

In a game that lists its levels, a match won in a victory finishes the level being played and keeps its best.

<MatchScreen> opens a Modal the step the match ends. Its pane holds a title and a line of text for the outcome, the score where the match has a score to win, and anything you pass as children. For a match with no queue, a “Play again” button below the pane calls restart(). A queued match resets itself after its results, so its screen shows no such button. Beside it stand any buttons of the game’s own that you pass as actions, such as one that plays the next level. The screen draws nothing until the match ends.

The screen titles a victory “You won”, a defeat “Defeat” and a draw “Draw”. A defeat on time, whose reason is "time", reads “Out of time”. A game replaces each title and its line with victoryTitle, victoryText, defeatTitle, defeatText, drawTitle and drawText, and a defeatTitle it passes wins whatever the reason. A game that names its own reasons, such as a crash or the forfeit’s "gave-up", reads the reason from useMatch() and passes a title for each.

In a game with levels, a small line above the title names the level and the count, such as “Level 2 of 4”. Pass noun to call the levels something else, such as "Track". A game with no levels shows no line.

In a match of teams, the score line is the sides’ score, the reader’s side first, such as “2 – 7”, rather than the reader’s own points.

Its props are in MatchScreenProps.

<MatchScore /> from @spawnite/engine/matches draws a match of teams’ score in one row at the top of the screen while the match runs: each side’s colour, its name and its score, the reader’s side first. The clock stands between the first two sides while the match has a time limit. A score pops as it changes. slot moves it, such as to Slot.TopRight for a game whose ball peaks over the top centre, clock={false} leaves the clock out, and match shows another match than the reader’s own, such as one the player watches from the park. It draws nothing outside a match of teams.

Its props are in MatchScoreProps.

A test of the screen renders the whole game, as A whole game in jsdom says.

Mount one <MatchScreen> beside the match it screens; a game replaces its title and text with its own, but keeps the score and the button.

The screen pauses the world while it is open, for a match with no queue. Pass pause={false} to let the world step on behind it, as a racer coasts past the line under the finish screen; the menu’s own pause still freezes it. A queued match’s screen never pauses it, since the match resets itself after resultSeconds.

To shoot the screen, step the playtest until the match ends: spawnite play screenshot --keys <start> --until false --budget 120, where <start> is the input that starts the game’s match, such as Space. For a match with no queue, the screen’s modal pauses the world, so the step stops on the step it opens and the shot shows it. A queued match’s screen stays open for resultSeconds only, so shoot it within that time.

A screen with pause={false} stops no step: step on with spawnite play step --seconds <s> past the end, then shoot. A second spawnite play screenshot --size 390x844, with no --until, shoots the same screen at another size. When the budget runs out with the match still waiting, the command says so: nothing in the steps started the match.

Room and page. The match has a consequence: it decides the game’s outcome, so the room’s copy is the one that counts, and the room streams it to every page. matches.advance runs on each page too, as its runsOn says, but leaves each match the room streams to the room: a page’s clock moves as the room’s sends arrive.

The step runs the match after the behaviours, so a coin picked up in a step counts in that step, and before a game’s own systems in the rules phase, unless one anchors before matches.systems.advance. So when the last coin, the clock and a game’s own endMatch land in one step, the match ends on the coin or the clock, and the game’s call returns false. The headless harness runs it too. Spawn the MatchTrait entity in the scene, and step the world. A trait spawned without fields takes the machine’s defaults of 60 seconds and one coin, where a <Match /> with no props has neither. Read the match back from the dump under match: its state under state, its outcome under outcome, and its state’s tag beside it, such as "match.playing": true. Its countdown counts from the tick under waitStartTick, and its clock runs from startedTick to endedTick.

The following scene’s Clock draws the score with a Counter, which bumps as each mushroom lands. A count the game adds beside it, such as the keys the player holds, takes a Counter of its own. The engine’s tests mount the Clock beside a match and check that the Counter shows each new score:

packages/engine/test/outside/Meadow.tsx
import {
CharacterSpawn,
Counter,
Hud,
Panel,
PanelVariant,
Slot,
Text,
Landscape,
} from "@spawnite/engine";
import {
Match,
MatchScreen,
MatchState,
useMatch,
} from "@spawnite/engine/matches";
export function Clock() {
const match = useMatch();
if (!match) return null;
const { remainingSeconds, score, scoreToWin, state } = match;
return (
<Hud>
<Panel slot={Slot.TopLeft} variant={PanelVariant.Glass}>
<Text>
{state === MatchState.Playing
? `${Math.ceil(remainingSeconds)} s`
: state}
</Text>
<Counter
icon="coins"
label="Mushrooms"
value={`${score} / ${scoreToWin}`}
/>
</Panel>
</Hud>
);
}
export function Meadow() {
return (
<Landscape map="meadow">
<CharacterSpawn />
<Match timeLimitSeconds={60} scoreToWin={8} />
<Clock />
<MatchScreen victoryText="Every mushroom, with time to spare." />
</Landscape>
);
}

The match is a readable plugin: the engine’s package ships its TypeScript source, with its comments, under node_modules/@spawnite/engine/readable/matches/, and the code a game runs is built from those files. The folder holds the following:

  • matches.ts: the plugin, its two systems in the rules phase, and the room’s answer to each command a page sends.
  • rule.ts: its answer to the match question.
  • rules.ts: the step that runs each match, the commands, and the verbs that start and end one.
  • members.ts, join.ts and queues.ts: slots, joins and the queue.
  • score.ts, places.ts and reset.ts: scores, places, and the reset by step.
  • laps.ts: the lap line and addMatchLap.
  • traits.ts, events.ts and verbs.ts: the match’s machine and its records, its events, and setMatch and its siblings.
  • index.ts: the names @spawnite/engine/matches exports.
  • Match.tsx, hooks.ts, MatchScreen.tsx and LevelBanner.tsx: the components and hooks.

Each file reaches the engine through its public entries alone, @spawnite/engine and @spawnite/engine/core, as a game’s own plugin does, so every call it makes is one a game can make. Read it to see how a plugin is built, or copy it into the game as the start of a match with rules of its own:

  1. Copy the folder into the game, such as src/plugins/race/. Its files are MIT-0, as the license says, so the copy is the game’s own. Its imports of the engine work unchanged.
  2. Give the copy a plugin name of its own, in definePlugin and in each requirePlugin call, and rename its machine’s id, so the copy and the engine’s match can never meet under one name.
  3. List the copy in the game’s plugins in place of matches(), beside machines(), which it requires. Move every import of the match’s names to the copy’s folder, its traits, its verbs and its handles among them, such as an anchor to matches.systems.advance. Otherwise, a query of the engine’s MatchTrait finds none of the copy’s matches, and an anchor to the engine’s system names a plugin the game no longer lists.