Rounds & Stock Levels
Players gather in a start zone, a countdown runs, the course opens, and a results board decides who did what. One component drives all four phases — and the difference between a co-op course and a cutthroat race is one dropdown.
Four phases
RoundController moves a level through Waiting → Countdown → Running → Finished, and every other piece of the level reacts to the phase rather than tracking its own idea of what is going on.
| Field | Type | What it does |
|---|---|---|
Waiting | RoundPhase | Players gather in the start zone. Gates are shut, players can be physically contained, and nothing is being timed yet. |
Countdown | RoundPhase | The run is committed to. The countdown ticks down on the HUD and the containment walls are about to drop. |
Running | RoundPhase | The course is open and the clock is going. Finishers are recorded as they arrive. |
Finished | RoundPhase | Results are up. After a configurable pause the level reloads for the next round — or a player leaves early via Back to Lobby. |
The two zones
A round needs a start zone and an end zone, each a TriggerBehaviour. They are ordinary trigger volumes — the same component any other rule uses — so a start pad can also run instructions, play a sound or gate on a condition without becoming a special case.
The start zone answers "is everyone here yet?". The end zone answers "have you finished?" — and how strictly it answers that is a field, because getting it wrong is very visible:
| Field | Type | What it does |
|---|---|---|
Centre | FinishDepth | The default. The player's own position must be inside the volume — they have stepped onto the pad, not merely brushed it. |
Touching | FinishDepth | The instant any part of them overlaps. On a wide finish pad this declares a winner metres early. |
Deep | FinishDepth | They must reach the middle half of the volume. For finish areas you want players to commit to. |
Configuring the round
| Field | Type | Default | What it does |
|---|---|---|---|
startZone | TriggerBehaviour | — | Players gather here; the round starts when every player in the level stands inside. |
endZone | TriggerBehaviour | — | A participant entering here has finished the course. |
endCondition | RoundEndCondition | AllPlayersFinish | What ends the round before the time limit: everyone finishing (a co-op course) or the first finisher (a race, where everyone else becomes a DNF). |
outOfBoundsConsequence | OutOfBoundsConsequence | Respawn | What happens when a participant falls out of bounds: respawn at their last checkpoint, or lose the round for everyone. |
finishDepth | FinishDepth | Centre | How far into the end zone a player must be to have finished. |
disabledStats | StatDefinition[] | — | Stats switched off for everyone while this round runs. Add Stamina for a round where sprinting is free; add Health for one nobody can lose. Restored, and refilled, when the round ends. |
startKey | Key | Enter | Key the authority machine presses during Waiting to start the round. None means the round only starts via auto-start or StartRound(). |
autoStartWhenPlayers | int | 0 | Automatically start the countdown once this many players stand in the start zone. 0 means the round only starts via the start key or StartRound(). |
countdownSeconds | float | 3 | Length of the countdown phase. |
timeLimitSeconds | float | 180 | Round time limit. Unfinished participants become DNFs when it expires. |
resultsSeconds | float | 8 | How long the results stay up before the next round starts and the level reloads. Players can leave earlier via Back to Lobby. |
closeMovablesOnStart | bool | true | Close every MovableObject — gates, doors, shutters — when the round starts, so each run begins identically. |
containPlayersUntilStart | bool | true | Keep players physically inside the start zone with invisible walls around its box collider until the round starts. |
containFinishedPlayers | bool | true | Keep finished players inside the end zone until the round is over; leaving snaps them back. |
Making a level a round
Mark the two zones
Put aTriggerBehaviouron a start volume and another on a finish volume. Box colliders marked as triggers; size them generously for the start, deliberately for the finish.Add the controller
AddRoundControllerto the level and assign both zones.Pick the shape of the game
AllPlayersFinishfor a co-op course everyone completes together,FirstPlayerFinishesfor a race. Set the time limit; it is what turns a stuck round into a results board rather than a stalemate.Decide the rules of the run
Out-of-bounds respawn or game over. Stats to switch off. Whether gates re-close each round. Every one of these is a field, and none of them is code.
Results
Each participant gets a RoundPlayerResult: whether they finished, their finish time in seconds since the round started, and how many times they failed. Unfinished players at the time limit are DNFs, and in a FirstPlayerFinishes round everyone but the winner is.
Subscribe to OnPhaseChanged and OnResultsChanged rather than polling — the HUD does, and so should anything you add:
using Curitor.PartyCoreKit.Rounds;
using UnityEngine;
public class RoundWatcher : MonoBehaviour
{
private void OnEnable()
{
RoundController round = RoundController.Active;
if (round == null) return;
round.OnPhaseChanged += HandlePhase;
round.OnResultsChanged += HandleResults;
}
private void OnDisable()
{
RoundController round = RoundController.Active;
if (round == null) return;
round.OnPhaseChanged -= HandlePhase;
round.OnResultsChanged -= HandleResults;
}
private void HandlePhase(RoundPhase phase)
{
// PhaseRemaining counts down within Countdown, Running and Finished.
Debug.Log($"{phase}, {RoundController.Active.PhaseRemaining:0.0}s left");
}
private void HandleResults()
{
foreach (RoundPlayerResult result in RoundController.Active.Results)
{
Debug.Log(result.Finished
? $"{result.PlayerId} finished in {result.FinishTime:0.00}s"
: $"{result.PlayerId} DNF after {result.Fails} fails");
}
}
}When the end zone is not the answer
A lap race does not finish by entering a volume — it finishes after three laps, which happens to pass through the same volume twice before it counts. So "has this player finished?" is an extension seam, not a hard-coded zone test:
namespace Curitor.PartyCoreKit.Rounds
{
public interface IRoundFinishSource
{
bool HasFinished(GameObject playerRoot);
}
}Call SetFinishSource on the controller and your rule replaces the zone test entirely. LapTracker is the shipped implementation, and it is worth reading as the reference: checkpoints in order, a lap count, and a finish that only counts once the laps are done. Every gate a runner passes in order also becomes their respawn checkpoint (Gates Are Checkpoints on the Lap Tracker, on by default), so a fall sends them back to the last gate they passed, facing the next one — not to the start line.
Open it: SceneFlow/Levels/RoundsShowcase.unity is this pattern built and running — four gates in route order, a LapTracker wired up in the hierarchy, and a door that stays shut until the final lap. The door is an ordinary Trigger with a lap condition on it; swap that condition for Has Item or Compare Number and the same wiring becomes a key door or a score gate. The scene also runs as a best-of-three RoundSeries (below) — select the Round object to see it configured, and check the standings on the results panel between rounds.
Series: best of N
RoundController on its own loops forever — Finished auto-reloads back to Waiting, round after round, with no concept of a match. RoundSeriesis the opt-in aggregate on top: drop it on the same GameObject as the controller and it tallies points from each round's finish placement, stops the auto-reload once the series is decided, and leaves the final standings up instead of quietly starting round 4.
| Field | Type | Default | What it does |
|---|---|---|---|
mode | SeriesMode | RoundsToPlay | RoundsToPlay: play exactly roundsToPlay rounds, then stop. FirstToNWins: stop the moment any participant's round-win count reaches winsNeeded — the series can end early. |
roundsToPlay | int | 3 | RoundsToPlay mode's target round count — "best of 3". |
winsNeeded | int | 2 | FirstToNWins mode's target win count — "first to 2 wins". |
pointsByPlacement | int[] | [3, 2, 1, 0] | Points awarded by finish placement for one round — index 0 is 1st place. A placement past the end of the list scores 0. |
Placement comes from the exact same ordering the results screen already shows (finishers by time, then DNFs by however far they got) — the series can never rank a round differently than the player just watched it resolve on screen. A round win (forFirstToNWins) is 1st place with Finished true; an unfinished leader does not bank a win.
The seam: vetoing the auto-reload
RoundSeries is not special-cased into RoundController — it plugs into the same kind of small seam IRoundFinishSourceuses for a custom finish rule, one level later in the round's life:
namespace Curitor.PartyCoreKit.Rounds
{
public interface IRoundContinuationSource
{
// True lets the round's own auto-reload proceed, unchanged.
// False vetoes it: the round stays in RoundPhase.Finished, frozen
// on its current results, and does NOT reload. Authority-side
// only, consulted once per round end.
bool ShouldAutoReload();
}
}RoundController.SetContinuationSource(source) installs one; null (the default) is exactly the old behaviour — every round auto-reloads. This is the smallest seam that madeRoundSeriespossible without touching the round's own state machine, and it is just as available for a custom match structure — a tournament bracket, a sudden-death decider, anything that needs the last word on whether a finished round moves on.