Skip to content

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.

  1. Track hierarchy

    • Create a Race root 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) with Is Trigger = true. Size it so the driver’s player collider passes through.
    • Add the Checkpoint component to each.
      • RaceController → drag the root.
      • Index → integer, starting at 0 for the start/finish, then 1, 2, … in the order drivers will cross them on a lap.
  2. HUD

    • Add a world-space Canvas in the track root (or on a billboard near the track).
    • Three TextMeshProUGUI children: Lap, Time, Best, and one optional Status.
    • On the RaceController, wire those four into lapLabel, timeLabel, bestLabel, statusLabel.
    • Set orderedCheckpoints in the inspector by dragging the checkpoint components in race order — first entry should be the start/finish.
    • Set totalLaps (default 3).
  3. Project setup

    • CCKWasmProjectDescriptor on the world root.
    • Either include the two scripts as components (they are) or add them to includedScripts.
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.

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).

raceActive = false, raceFinished = false <- idle
| (player crosses checkpoint 0)
v
raceActive = true, currentLap = 1, expectedIndex = 1, lapStartTime = now
| (player crosses 1, 2, ..., N-1 in order)
| expectedIndex advances each hit
v
expectedIndex == 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.

  • OnTriggerEnter(Collider) — Unity physics event on the checkpoint’s own collider. No CheckAccess for the dispatch itself.
  • LocalPlayer.PlayerObject.GetGameObject() — requires ObjectContext = 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).

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.

  • Ghost mode — record LocalPlayer.GetPosition() each FixedUpdate into 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 raceActive flips true. Use Time.time deltas.
  • Sector times — capture Time.time - lapStartTime at each checkpoint and show +/- against the best run.
  • Restart button — world-space UI button with OnClick () calling RaceController.ResetRace().