04 — Racing System with Laps
TL;DR: Two co-operating behaviours on a world GameObject hierarchy. Checkpoint sits on each checkpoint’s trigger volume and reports crossings to a single RaceController, which validates the order, tracks the current lap, counts best lap time, and updates a TMP HUD. Everything is world-context so CheckAccess is trivially satisfied; the interesting work is in the state machine.
Scene setup (World context)
Section titled “Scene setup (World context)”-
Track hierarchy
- Create a
Raceroot GameObject somewhere in the world. - Add
RaceController(the component). - Child GameObjects:
Checkpoint_Start,Checkpoint_1,Checkpoint_2, … — one per physical checkpoint. - On each checkpoint GameObject, add a
BoxCollider(or other primitive) withIs Trigger = true. Size it so the driver’s player collider passes through. - Add the
Checkpointcomponent to each.RaceController→ drag the root.Index→ integer, starting at0for the start/finish, then1,2, … in the order drivers will cross them on a lap.
- Create a
-
HUD
- Add a world-space
Canvasin the track root (or on a billboard near the track). - Three
TextMeshProUGUIchildren:Lap,Time,Best, and one optionalStatus. - On the
RaceController, wire those four intolapLabel,timeLabel,bestLabel,statusLabel. - Set
orderedCheckpointsin the inspector by dragging the checkpoint components in race order — first entry should be the start/finish. - Set
totalLaps(default3).
- Add a world-space
-
Project setup
CCKWasmProjectDescriptoron the world root.- Either include the two scripts as components (they are) or add them to
includedScripts.
Checkpoint
Section titled “Checkpoint”using UnityEngine;using WasmScripting;using CVR;
public partial class Checkpoint : WasmBehaviour{ public RaceController raceController; public int index;
void OnTriggerEnter(Collider other) { if (raceController == null) return; if (!IsLocalPlayerCollider(other)) return; raceController.ReportCheckpoint(index); }
private static bool IsLocalPlayerCollider(Collider c) { if (c == null) return false; Player localPlayer = LocalPlayer.PlayerObject; if (localPlayer == null) return false; GameObject root = localPlayer.GetGameObject(); if (root == null) return false; Transform cRoot = c.transform.root; return cRoot == root.transform || c.transform == root.transform; }}Why Unity’s OnTriggerEnter(Collider) instead of the game event OnPlayerTriggerEnter? The game-event variant is single-argument: void OnPlayerTriggerEnter(Player player). It fires whenever any player crosses any trigger in the script’s content, and does not tell you which trigger — useless when you have N checkpoints that need to disambiguate. OnTriggerEnter fires on the specific checkpoint GameObject, so this.index identifies the crossing.
The IsLocalPlayerCollider check uses Transform.root (unrestricted by scope) rather than Transform.IsChildOf (scope-restricted to Self), because a remote player’s collider may land in an external scope and IsChildOf would throw.
RaceController
Section titled “RaceController”using UnityEngine;using TMPro;using WasmScripting;
public partial class RaceController : WasmBehaviour{ public Checkpoint[] orderedCheckpoints; public int totalLaps = 3;
public TextMeshProUGUI lapLabel; public TextMeshProUGUI timeLabel; public TextMeshProUGUI bestLabel; public TextMeshProUGUI statusLabel;
[WasmSerialized] private int currentLap; [WasmSerialized] private int expectedIndex; [WasmSerialized] private float lapStartTime; [WasmSerialized] private float bestLapTime = float.MaxValue; [WasmSerialized] private bool raceActive; [WasmSerialized] private bool raceFinished;
[WasmSerialized] private int lastRenderedHundredthsSecond;
void Start() { ResetState(); RefreshHud(); }
void Update() { // Throttle HUD writes to ~100 Hz max (actual update rate is limited by display precision). if (!raceActive || raceFinished || timeLabel == null) return; float t = Time.time - lapStartTime; int hundredths = (int)(t * 100f); if (hundredths == lastRenderedHundredthsSecond) return; lastRenderedHundredthsSecond = hundredths; timeLabel.text = FormatTime(t); }
[ExternallyVisible] public void ReportCheckpoint(int checkpointIndex) { /* validates sequence, advances lap */ } [ExternallyVisible] public void ResetRace() { /* reset state for a new run */ } // ...}Source files: examples/04_Checkpoint.cs, examples/04_RaceController.cs (full listing of RaceController).
The state machine
Section titled “The state machine”raceActive = false, raceFinished = false <- idle | (player crosses checkpoint 0) vraceActive = true, currentLap = 1, expectedIndex = 1, lapStartTime = now | (player crosses 1, 2, ..., N-1 in order) | expectedIndex advances each hit vexpectedIndex == 0, player crosses checkpoint 0 again | lap complete | if lapTime < bestLapTime: update bestLapTime | if currentLap == totalLaps: raceFinished = true, raceActive = false | else: currentLap++, lapStartTime = now, expectedIndex = 1 v(back to waiting for next checkpoint)Out-of-order crossings set a status message (“Missed checkpoint N — go back!”) but do not reset the lap. You can change ReportCheckpoint to either reset the lap or invalidate the lap time if you want stricter semantics.
Permission model
Section titled “Permission model”OnTriggerEnter(Collider)— Unity physics event on the checkpoint’s own collider. NoCheckAccessfor the dispatch itself.LocalPlayer.PlayerObject.GetGameObject()— requiresObjectContext = World. Because this is a world script, satisfied.Collider.transform,Transform.root— both(Any, Any, Any). Safe even for remote players’ external-scope transforms.Transform.IsChildOf(Transform)—(Any, Any, Self). Deliberately avoided here because a remote player’s collider may be external-scope and throw.Time.time, TMP text writes — all safe.
An avatar or prop script cannot call LocalPlayer.PlayerObject because it’s World-gated. If you adapt this example for a prop race track, you’ll need a different player-detection strategy (e.g. layer/tag-based filtering).
Why this is client-local
Section titled “Why this is client-local”ReportCheckpoint only fires for the local player (the IsLocalPlayerCollider guard). Each client runs its own race clock — no attempt to sync lap times across the network. That is intentional for:
- Correctness — the authoritative answer for “did the local user hit the checkpoint” lives on their own client; no spoofing from remote inputs.
- Simplicity — no ordering / authority concerns.
For a multi-player leaderboard, add a second phase that sends a “I just finished” Reliable message via WasmScripting.Networking.SendMessage to everyone on the last-lap completion, collect them on each client, and render into an additional HUD label. See Networking.
Extensions to try
Section titled “Extensions to try”- Ghost mode — record
LocalPlayer.GetPosition()eachFixedUpdateinto a ring buffer during a best lap; play it back on the next attempt at a translucent ghost GameObject. - Countdown — add a 3-2-1-GO phase before
raceActiveflips true. UseTime.timedeltas. - Sector times — capture
Time.time - lapStartTimeat each checkpoint and show+/-against the best run. - Restart button — world-space UI button with
OnClick ()callingRaceController.ResetRace().
Related
Section titled “Related”- Events → Script-defined events — how to bind a restart button.
- Permissions — why
LocalPlayerrequires world context. - Networking — adding cross-client leaderboards.