Round
Round times a round of play: “collect 8 mushrooms before 60 seconds run out”, or “two laps, then the finish”. The score is the coins that addCoins credits while the round plays, a Pickup’s among them. The round is won the step the score reaches the target or the last lap is in, and lost the step the time runs out first. A rule you leave out is off: a round with no seconds 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 Round carries both, so a game configures a race rather than writing one.
Its props are in RoundProps.
Round is the rounds() plugin: list rounds() in the game’s plugins, and import every name on this page from @spawnite/engine/rounds. Its source ships readable in the package, as Read its source says.
The round 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 is won or lost, the round keeps its clock and its score.
A timer of the game’s own, such as a wave of enemies every 30 seconds, is a states() wait or a system on the step, as the round’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 round is a machine, RoundMachine: its states are the RoundState values, the step sends it WIN and LOSE, and its countdown is a wait. Each state is a tag, so world.queryFirst(RoundMachine.is.playing) finds a round in play. RoundMachine.read(entity).secondsLeft reads the countdown left, as useRound() does.
Put Round 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 race
Section titled “A race”A race starts on a signal, counts down, and ends when the player crosses the line, or when the game says it failed. Round covers each part:
ready: the round waits inRoundState.Ready, its clock held, untilstart(). A sling’s release or a Start button calls it.countdown: seconds inRoundState.Countdownbefore it plays. Withoutready, the countdown starts when the round mounts.laps: the laps that win it. Each entry into a TrackTrigger markedlapcounts one while the round plays. Start the mover past the line, or its first crossing counts.finish()wins a round in play, andfail(reason)loses one that has not ended. The round keeps the reason, a crash, a fall, a false start, anduseRound()reads it back, so a screen can say why.<RoundScreen>titles every loss “Out of time” unless the game passes its ownlostTitle. A round that runs out of time has the reason"time".
A system calls startRound(world), finishRound(world) and failRound(world, reason) from @spawnite/engine/rounds; a component calls the same verbs from useRound(). In a game played alone, either one ends the round. In a game with a room, the room decides the round, as In a room says: a system ends it there, since a system runs on the room unless it says otherwise. The exit gate on the Trigger page wins the round from a system when a player walks in with the keys, rather than from onEnter.
A round that only the game ends, such as the exit gate’s, takes no target and no seconds: a bare <Round /> has no score to win it and no clock to lose it, so finishRound and failRound are the only ways it ends. A large target, such as 999, is not needed to keep coins from winning it.
In a room
Section titled “In a room”The room plays the round. The scene’s <Round> 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 <Round> spawns nothing, so useRound() reads the round the room streams. A room runs one scene, so put the <Round> in the scene the room runs, the one package.json names under spawnite.room.scene. A page in a room whose <Round> finds no round the room streams throws in development, naming that fix, and logs it in a published game.
The room decides the round, 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, startRound, finishRound and failRound start, win and lose the round every page shows. On a page in a room, useRound()’s verbs work as follows:
start()sends the room therounds.startcommand, and the room starts its round where the round is ready.restart()sends the room therounds.restartcommand, and once the room’s round is won or lost, the room starts its scene again, so every page gets the new round. “Play again” on the round’s screen works in a room this way. The players keep their characters where they stand. The room steps no more while its scene mounts 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.- The room refuses a start or a restart at any other time with a
RoundRefusal, which the command’s result carries to her page, and plays on:not-readyfor a start where no round is ready,not-endedfor a restart where no round is won or lost, andno-scenefor a restart on a room whose scene is a spawn function, as a test’s. finish()andfail(reason)throw in development, naming the fix, and do nothing in a published game.
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 round waits in RoundState.Ready until the first page opens the scene: that page’s <Round> finds the round the room streams ready and sends rounds.start for it. A restart’s new round waits the same way, and a page that has the scene open starts it. A page that opens the scene once the round plays or has ended sends nothing. Where two pages open the scene in the same moment, both send rounds.start: the room starts the round 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 round counts down or plays. A round with ready 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 round starts when the scene opens.
To win or lose the round from a button, send the room a command, and call finishRound(world) or failRound(world, reason) in a system that runs on the room and reads it. The following plugin loses the round for every page when one player gives up:
import type { World } from "koota";import { defineCommand, definePlugin, readCommands, type AuthoritativeStep,} from "@spawnite/engine/core";import { failRound } from "@spawnite/engine/rounds";
// A Give up button for a round in a room, 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 on the room loses the round every page shows.
/** The reason the round keeps, which the round's screen reads back. */export const forfeitReason = "gave-up";
/** Loses the round where a player gave up since the last step. */function takeForfeits(world: World, step: AuthoritativeStep) { for (const command of readCommands(step, forfeit.commands.giveUp)) { failRound(world, forfeitReason); command.accept(); }}
export const forfeit = definePlugin({ name: "forfeit", commands: { giveUp: defineCommand({}) }, // A command's reader runs on the room alone, which decides the round. systems: { rules: { takeForfeits: { system: takeForfeits, answers: ["giveUp"] } }, },});List forfeit among the game’s plugins, beside rounds(). The Give up button calls sendCommand(world, forfeit.commands.giveUp, {}), with sendCommand from @spawnite/engine and the world from koota’s useWorld. Each page then reads reason from useRound() as "gave-up".
In a game played alone, each verb changes the round on the page’s own world, as the sections above say.
Won on a boss’s death
Section titled “Won on a boss’s death”A round 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 and calls finishRound. 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 round with its death:
import type { World } from "koota";import { definePlugin, definePrefab, defineTrait, DiedEvent, readEvents, type AuthoritativeStep,} from "@spawnite/engine/core";import { Entity, Health } from "@spawnite/engine";import { finishRound } from "@spawnite/engine/rounds";
/** Marks the boss whose death wins the round. */export const BossTrait = defineTrait("outsideBoss");
export const bossWin = definePlugin({ name: "boss-win", // No runsOn: the room alone wins a round, and a game played alone is // its own room. systems: { rules: { win: { system: winOnBossDeath } } },});
/** Wins the round in the step the boss dies. */function winOnBossDeath(world: World, step: AuthoritativeStep) { for (const { entity } of readEvents(step, DiedEvent)) if (entity?.has(BossTrait)) finishRound(world);}
/** The boss, with the mark whose death wins the round. */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 rounds() among the game’s plugins, and place <Boss /> in the scene.
Reading it
Section titled “Reading it”useRound() returns a RoundStatus: the seconds left and played, the countdown left, the score and its target, the laps done and wanted, the RoundState, the reason it was lost, the verbs above, and restart(), which starts the round’s scene again.
The score counts only what addCoins credits while the round is in RoundState.Playing:
- A coin credited while the round is ready or counting down adds nothing. The one exception: a coin a system credits between the round’s step and the machines’ step, in the step a countdown ends, counts toward the round 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 round on its first step.
- A spend, such as a shop’s
spendCoins, never lowers the score. - Every wallet’s credits count toward the one round: in a room, each player’s coins add to the room’s round.
The round scores in its step, and startRound, finishRound and failRound score before they change a round, so a coin a game’s system credited earlier in the step counts toward the round it ends and never toward one it starts. A round scores first as it spawns too, from <Round> or bare, so it counts only the coins credited after it.
What the round 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.
Before a round mounts, useRound() reads a fresh one from the machine’s defaults: 60 seconds and a target of one coin.
In a game that lists its levels, a won round finishes the level being played and keeps its best.
Its screen
Section titled “Its screen”<RoundScreen> opens a Modal the step the round is won or lost. Its pane holds a title, a line of text, the score where the round has a target, and anything you pass as children. Below the pane stands a “Play again” button that calls restart(), and beside it 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 round ends.
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.
Its props are in RoundScreenProps.
Mount one <RoundScreen> beside the round 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. 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.
To shoot the screen, step the playtest until the round ends: spawnite play screenshot --keys <start> --until false --budget 120, where <start> is the input that starts the game’s round, such as Space. The screen’s modal pauses the world, so the step stops on the step it opens and the shot shows it. 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 round still ready, the command says so: nothing in the steps started the round.
Where it runs
Section titled “Where it runs”Room and page. The round 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. rounds.advance runs on each page too, as its runsOn says, but leaves each round the room streams to the room: a page’s clock moves as the room’s sends arrive.
The step runs the round after the behaviours, so a coin picked up in a step counts in that step. The headless harness runs it too: spawn the RoundTrait entity in the scene, which takes the machine’s defaults of 60 seconds and one coin for each field you leave out, step the world, and read the round back from the dump under round, its state under state and its countdown’s seconds so far under waited, and its state’s tag beside it, such as "round.playing": true.
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 round and check that the Counter shows each new score:
import { CharacterSpawn, Counter, Hud, Panel, Slot, Text, World,} from "@spawnite/engine";import { Round, RoundScreen, RoundState, useRound,} from "@spawnite/engine/rounds";
export function Clock() { const { secondsLeft, score, target, state } = useRound(); return ( <Hud> <Panel slot={Slot.TopLeft}> <Text> {state === RoundState.Playing ? `${Math.ceil(secondsLeft)} s` : state} </Text> <Counter icon="coins" label="Mushrooms" value={`${score} / ${target}`} /> </Panel> </Hud> );}
export function Meadow() { return ( <World map="meadow"> <CharacterSpawn /> <Round seconds={60} target={8} /> <Clock /> <RoundScreen wonText="Every mushroom, with time to spare." /> </World> );}Read its source
Section titled “Read its source”The round is a readable plugin: the engine’s package ships its TypeScript source, with its comments, under node_modules/@spawnite/engine/readable/rounds/, and the code a game runs is built from those files. The folder holds the following:
rounds.ts: the plugin, one system in therulesphase, and the room’s answer to a page’s start and restart.rules.ts: the system that scores and times each round, the commands a page sends, and the verbs that start, win and lose one.traits.ts: the round’s machine and its states.index.ts: the names@spawnite/engine/roundsexports.Round.tsx,RoundScreen.tsxandLevelBanner.tsx: the components.
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 round with rules of its own:
- Copy the folder into the game, such as
src/plugins/race/, keeping the license lines at the top of each file, as the license says. Its imports of the engine work unchanged. - Give the copy a plugin name of its own, in
definePluginand in eachrequirePlugincall, and rename its machine’sid, so the copy and the engine’s round can never meet under one name. - List the copy in the game’s plugins in place of
rounds(), besidemachines(), which it requires. Move every import of the round’s names to the copy’s folder, its traits, its verbs and its handles among them, such as an anchor torounds.systems.advance: a query of the engine’sRoundTraitfinds none of the copy’s rounds, and an anchor to the engine’s system names a plugin the game no longer lists.