Curitor
Level & Flow · Topic Guide

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 WaitingCountdown RunningFinished, and every other piece of the level reacts to the phase rather than tracking its own idea of what is going on.

FieldTypeWhat it does
WaitingRoundPhasePlayers gather in the start zone. Gates are shut, players can be physically contained, and nothing is being timed yet.
CountdownRoundPhaseThe run is committed to. The countdown ticks down on the HUD and the containment walls are about to drop.
RunningRoundPhaseThe course is open and the clock is going. Finishers are recorded as they arrive.
FinishedRoundPhaseResults 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:

FieldTypeWhat it does
CentreFinishDepthThe default. The player's own position must be inside the volume — they have stepped onto the pad, not merely brushed it.
TouchingFinishDepthThe instant any part of them overlaps. On a wide finish pad this declares a winner metres early.
DeepFinishDepthThey must reach the middle half of the volume. For finish areas you want players to commit to.

Configuring the round

FieldTypeDefaultWhat it does
startZoneTriggerBehaviourPlayers gather here; the round starts when every player in the level stands inside.
endZoneTriggerBehaviourA participant entering here has finished the course.
endConditionRoundEndConditionAllPlayersFinishWhat 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).
outOfBoundsConsequenceOutOfBoundsConsequenceRespawnWhat happens when a participant falls out of bounds: respawn at their last checkpoint, or lose the round for everyone.
finishDepthFinishDepthCentreHow far into the end zone a player must be to have finished.
disabledStatsStatDefinition[]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.
startKeyKeyEnterKey the authority machine presses during Waiting to start the round. None means the round only starts via auto-start or StartRound().
autoStartWhenPlayersint0Automatically start the countdown once this many players stand in the start zone. 0 means the round only starts via the start key or StartRound().
countdownSecondsfloat3Length of the countdown phase.
timeLimitSecondsfloat180Round time limit. Unfinished participants become DNFs when it expires.
resultsSecondsfloat8How long the results stay up before the next round starts and the level reloads. Players can leave earlier via Back to Lobby.
closeMovablesOnStartbooltrueClose every MovableObject — gates, doors, shutters — when the round starts, so each run begins identically.
containPlayersUntilStartbooltrueKeep players physically inside the start zone with invisible walls around its box collider until the round starts.
containFinishedPlayersbooltrueKeep finished players inside the end zone until the round is over; leaving snaps them back.

Making a level a round

  1. Mark the two zones

    Put a TriggerBehaviour on a start volume and another on a finish volume. Box colliders marked as triggers; size them generously for the start, deliberately for the finish.
  2. Add the controller

    Add RoundController to the level and assign both zones.
  3. Pick the shape of the game

    AllPlayersFinish for a co-op course everyone completes together, FirstPlayerFinishes for a race. Set the time limit; it is what turns a stuck round into a results board rather than a stalemate.
  4. 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.

FieldTypeDefaultWhat it does
modeSeriesModeRoundsToPlayRoundsToPlay: play exactly roundsToPlay rounds, then stop. FirstToNWins: stop the moment any participant's round-win count reaches winsNeeded — the series can end early.
roundsToPlayint3RoundsToPlay mode's target round count — "best of 3".
winsNeededint2FirstToNWins mode's target win count — "first to 2 wins".
pointsByPlacementint[][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.

Multiplayer