Advanced Customization
This guide is for developers who want to go beyond the defaults — custom UIs, custom race rules, or subscribing to timing events in code.
1) Subscribe to events (C#)
Subscribe directly to LapTimer events from any MonoBehaviour:
using UnityEngine;
using SlowToast.RaceTiming.Adapter;
using SlowToast.RaceTiming.Core;
using SlowToast.RaceTiming.Core.Events;
public class RaceAnnouncer : MonoBehaviour
{
private LapTimer _timer;
void Start()
{
_timer = RaceTimingManager.Instance.LapTimer;
_timer.LapCompleted += OnLapCompleted;
_timer.NewSessionBestLap += OnNewSessionBestLap;
}
void OnDestroy()
{
if (_timer == null) return;
_timer.LapCompleted -= OnLapCompleted;
_timer.NewSessionBestLap -= OnNewSessionBestLap;
}
private void OnLapCompleted(LapCompletedEventArgs e)
=> Debug.Log($"Car {e.CompetitorId} lap {e.LapData.LapNumber} in {e.LapData.Duration:F3}s");
private void OnNewSessionBestLap(SessionBestLapEventArgs e)
=> Debug.Log($"SESSION BEST: {e.NewSessionBestLap.Duration:F3}s by {e.CompetitorId}");
}
Prefer Inspector wiring?
RaceTimingManagerexposes the same events as UnityEvents — no code required.
2) Build your own UI
A good pattern for race UIs: - Use events for discrete moments (lap complete, position changed) - Poll for values that update every frame (live gaps, track position)
Get the current session state
var session = RaceTimingManager.Instance.LapTimer.Session;
Get on-track intervals relative to a driver
var intervals = RaceTimingManager.Instance.LapTimer.QueryTrackPositionIntervals(referenceDriverId);
Get normalized lap positions for a mini-map or track bar
If you want to draw dots on a map or bar, you can query normalized track positions (0.0 = start line, 0.5 = halfway around the lap) for the whole field.
var lapTimer = RaceTimingManager.Instance.LapTimer;
// Optionally, query relative to a specific focused driver to ensure they are the reference point
// (Pass 0 to ignore focus)
int focusedId = CompetitorFocusManager.Instance?.FocusedCompetitorId ?? 0;
var trackPositionResult = lapTimer.QueryTrackPositionIntervals(focusedId);
if (trackPositionResult == null || trackPositionResult.Entries.Count == 0) return;
foreach (var entry in trackPositionResult.Entries)
{
int competitorId = entry.CompetitorId;
// Normalized position along the track (0.0 - 1.0)
float normalizedPosition = (float)entry.TrackPosition;
// Optional: Draw your UI element using `normalizedPosition` to place it on the screen
Debug.Log($"Driver {competitorId} is at {normalizedPosition * 100}% of the lap.");
}
Get the current race standings and competitor data
The LapTimer provides zero-allocation accessors to get the current sorted race order, and the dictionary containing all competitor session data. Here's how to loop through the field in position order:
var lapTimer = RaceTimingManager.Instance.LapTimer;
// StandingsOrder is a list of Competitor IDs, sorted by the active ranking strategy (e.g., P1, P2, P3...)
IReadOnlyList<int> standingsOrder = lapTimer.StandingsOrder;
// Competitors is a dictionary containing all timing data (laps, gaps, splits) per driver ID
IReadOnlyDictionary<int, CompetitorSessionData> competitors = lapTimer.Competitors;
// Iterate through the current race order
for (int i = 0; i < standingsOrder.Count; i++)
{
int competitorId = standingsOrder[i];
if (competitors.TryGetValue(competitorId, out CompetitorSessionData compData))
{
int currentPosition = i + 1;
Debug.Log($"P{currentPosition}: Driver {competitorId} - Laps Completed: {compData.Laps.Count} - Gap: {compData.GapToLeader:F3}s");
}
}
Get Session Status and Progress
To display a race timer or progress bar, use the session state and the GetSessionProgress() helper.
var lapTimer = RaceTimingManager.Instance.LapTimer;
// SessionState indicates if the session is Idle, Running, or Finished
if (lapTimer.State == SessionState.Running)
{
// Optional: Get formatted rules-based progress (e.g. "Lap 5/10" or "12:34 remaining")
SessionProgress? progress = lapTimer.GetSessionProgress();
if (progress.HasValue)
{
Debug.Log(progress.Value.DisplayText);
// ProgressRatio is a 0.0 - 1.0 float perfect for UI fill bars
float fillAmount = (float)progress.Value.ProgressRatio;
}
}
Get Fastest Laps, Sectors, and Ideal Lap
You can easily grab the ultimate fastest lap/sectors of the session, or query a specific driver's personal bests.
var lapTimer = RaceTimingManager.Instance.LapTimer;
// Get the absolute fastest lap of the session (zero-allocation)
Lap sessionFastestLap = lapTimer.SessionBestLap;
if (sessionFastestLap != null)
{
Debug.Log($"Fastest Lap: {sessionFastestLap.Duration:F3}s");
}
// Get session best sectors
foreach (var sector in lapTimer.SessionBestSectorTimes.Values)
{
Debug.Log($"Sector {sector.SectorId} Best: {sector.Time:F3}s (Lap {sector.LapNumber})");
}
// Get personal bests for a specific driver
if (lapTimer.Competitors.TryGetValue(1, out CompetitorSessionData compData))
{
Lap driverBestLap = compData.BestLap;
if (driverBestLap != null)
{
Debug.Log($"Driver 1 Personal Best: {driverBestLap.Duration:F3}s");
}
// Get the calculated ideal/optimum lap (sum of all personal best sectors)
if (compData.IdealLapTime.HasValue)
{
Debug.Log($"Driver 1 Optimum/Ideal Lap: {compData.IdealLapTime.Value:F3}s");
}
}
Get Official Race Results (Frozen Finishes)
When a driver crosses the finish line at the end of a race, their final position and gaps are permanently locked so the results UI won't change even if they keep driving.
var lapTimer = RaceTimingManager.Instance.LapTimer;
if (lapTimer.Competitors.TryGetValue(1, out CompetitorSessionData compData))
{
// Check if the competitor has officially finished the race
if (compData.FinishResult.HasFinished)
{
Debug.Log($"Finished P{compData.FinishResult.Position}");
Debug.Log($"Official Gap to Leader: {compData.FinishResult.GapToLeader:F3}s");
}
}
2b) Key Core Types
These structs and classes are used throughout the API and are useful to understand when building custom integrations.
LapCrossingResult
Returned by LapTimer.UpdatePosition(). Indicates whether a lap crossing was detected during the position update.
public struct LapCrossingResult
{
public bool CrossingDetected; // True if a lap/sector line was crossed
public bool IsForward; // True if crossing was forward (normal direction)
public double InterpolatedTime; // Exact sub-tick time of the crossing
public double InterpolationFactor; // t-value used for interpolation (0-1)
}
DeltaBarData
Returned by LapTimer.GetDeltaBarData(). Contains all values needed to render a delta bar.
public struct DeltaBarData
{
public double TotalDelta; // Clamped delta vs reference (positive = slower)
public double UnclampedDelta; // Actual delta without clamping
public double MicroSectorDelta; // Delta within current micro-sector
public double MaxDelta; // Maximum delta for clamping (default 9.999s)
public double BarFillAmount; // Normalized 0-1 for UI bar fill
public bool IsActive; // True if reference lap exists and calculation is active
}
MicroSectorLapRecord
Fixed-size array of elapsed timestamps at each micro-sector boundary. Used for delta bar calculations.
public struct MicroSectorLapRecord
{
public int TotalMicroSectors; // Total count of micro-sectors
public bool IsValid; // True if timestamps array is populated
public double this[int index]; // Indexer for individual micro-sector timestamps
public double[] ToArray(); // Returns a copy of all timestamps
}
FinishResult
Captures a competitor's official finishing position and gaps at the moment they cross the finish line. Stored on CompetitorSessionData.FinishResult.
public readonly struct FinishResult
{
public int Position; // Finishing position (1 = winner). 0 = not yet finished
public double GapToLeader; // Gap to leader at finish time
public double GapToAhead; // Gap to car ahead at finish time
public double FinishTime; // Session time when finished
public bool HasFinished; // True if Position > 0
}
3) Custom ranking strategies
Ranking is pluggable via IRaceRankingStrategy:
public interface IRaceRankingStrategy
{
void UpdateStandings(Session session);
}
Built-in strategies:
- StandardRaceRanking — sorts by: finished position first, then HasCrossedStartLine (desc), laps (desc), then distance (desc) (this is the default)
- BestLapRanking — sorts by best lap time (asc); competitors without a lap go to the bottom
- PenaltyAwareStandardRaceRanking — same as StandardRaceRanking but excludes disqualified competitors. Requires IPenaltyManager in constructor.
- PenaltyAwareBestLapRanking — same as BestLapRanking but excludes disqualified competitors and uses only valid laps (via ILapInvalidationManager).
To add a custom strategy:
1. Implement IRaceRankingStrategy.
2. Assign it via LapTimer.RaceDirector.SetRankingStrategy(yourStrategy).
Note: To enable penalty-aware standings, you must explicitly set the strategy:
csharp var rd = RaceTimingManager.Instance.LapTimer.RaceDirector; rd.SetRankingStrategy(new PenaltyAwareStandardRaceRanking(rd.PenaltyManager));
4) Runtime Session Configuration
You can configure the session programmatically at runtime — useful for race setup menus, practice/qualifying/race mode switching, or any scenario where Inspector assets aren't sufficient.
All methods must be called before StartSession(). Calls during an active session are ignored with a warning.
If no
SessionConfigAssetis assigned in the Inspector, one is created automatically on first use.
Finish conditions
var manager = RaceTimingManager.Instance;
// Lap-count race (e.g. 10 laps)
manager.SetTargetLapCount(10);
// Timed session (e.g. 15-minute qualifying)
manager.SetTimedSession(
durationSeconds: 900f,
endMode: TimedSessionEndMode.Qualifying,
standingsOrder: TimedSessionStandingsOrder.BestLapTime);
// Free practice — no automatic finish
manager.ClearFinishCondition();
Start lights countdown
// Enable or disable the countdown
manager.SetCountdownEnabled(true);
// Customise the light sequence
manager.SetCountdownConfig(
lightCount: 5,
redLightIntervalMs: 1000,
greenWindowMinMs: 200,
greenWindowMaxMs: 3000,
preStartDelaySeconds: 30.0);
Delta bar
// Enable delta bar with 10 micro-sectors per sector
manager.SetDeltaBarConfig(enabled: true, microSectorsPerSector: 10);
5) Custom finish conditions
Finish conditions are pluggable via ISessionFinishCondition:
public interface ISessionFinishCondition
{
bool IsComplete(Session session, int competitorId);
IReadOnlyList<int> GetFinalOrder(Session session);
SessionProgress GetProgress(Session session);
}
Built-in:
- LapCountFinishCondition — ends the session when the leader completes the target lap count
- TimedFinishCondition — ends after a configured duration
Both are configured via SessionConfigAsset in the Inspector, or via the runtime configuration API above.
6) Custom sector definitions
Sectors are defined as ratios along the lap (0–1).
- By default, 3 equal sectors are generated automatically.
- To customise them: edit
sectorson theTrackMarkersSOasset in the Inspector, or drag the yellow sector handles in the Scene View.
Sector timing is driven by:
LapTimer.TriggerSector(competitorId, sectorId)
7) HUD Visibility Control
RaceHudController is a singleton that lets you show or hide any part of the race HUD — from a single element to an entire group — with optional fade transitions.
HUD Groups
Every built-in UI component belongs to one of four groups:
| Group | Contains |
|---|---|
Standings |
Race Tower, Lap Position Bar |
Timing |
Delta Bar, Qualifying Lap overlay, Session Info Header |
TrackMap |
(reserved for custom track map components) |
Ceremony |
(reserved for podium / results screens) |
You assign a component's group in the Inspector via the HUD Group field on any RaceHudElement component.
Show / hide a group
using SlowToast.RaceTiming.UI;
using SlowToast.RaceTiming.Core.Models;
// Hide the entire standings panel instantly
RaceHudController.Instance.SetGroupVisible(HudGroup.Standings, false);
// Show it again with a 0.5s fade
RaceHudController.Instance.SetGroupVisible(HudGroup.Standings, true, fadeDuration: 0.5f);
Show / hide everything at once
// Hide all HUD elements (e.g. during a cutscene)
RaceHudController.Instance.SetAllVisible(false, fadeDuration: 0.3f);
// Restore everything
RaceHudController.Instance.SetAllVisible(true, fadeDuration: 0.3f);
Query current visibility
bool standingsVisible = RaceHudController.Instance.IsGroupVisible(HudGroup.Standings);
React to visibility changes
RaceHudController.Instance.OnVisibilityChanged += args =>
{
Debug.Log($"Group {args.Group} is now {(args.IsVisible ? "visible" : "hidden")}");
};
Get a reference to a specific element
// Retrieve the RaceTowerUI instance managed by the controller
var tower = RaceHudController.Instance.GetElement<RaceTowerUI>();
Building your own HUD elements
To make a custom component participate in the group system, extend RaceHudElement:
using SlowToast.RaceTiming.UI;
using SlowToast.RaceTiming.Core.Models;
public class MyCustomOverlay : RaceHudElement
{
protected override void Awake()
{
base.Awake(); // Required — sets up CanvasGroup
}
protected override void OnEnable()
{
base.OnEnable(); // Required — registers with RaceHudController
}
protected override void OnDisable()
{
base.OnDisable(); // Required — unregisters from RaceHudController
}
private void Update()
{
if (!IsHudVisible) return; // Skip updates when hidden
// Your display logic here
}
protected override void OnBecameVisible() => Debug.Log("Overlay shown");
protected override void OnBecameHidden() => Debug.Log("Overlay hidden");
}
Set the HUD Group field in the Inspector to assign it to a group. It will then respond to SetGroupVisible() and SetAllVisible() calls automatically.
Prerequisite: A
RaceHudControllerGameObject must be present in the scene. Add the component to any persistent GameObject (e.g. alongsideRaceTimingManager).
8) Competitor Disconnect and Reconnect
Sirocco Race Timing distinguishes between two kinds of competitor absence:
| State | Use when | Timing data |
|---|---|---|
| Disconnected | Temporary — player may return | Retained |
| Withdrawn | Permanent — DNF, retired | Retained but excluded |
Disconnect (temporary absence)
A disconnected competitor is excluded from live standings and gap calculations, but their lap history and best times are preserved. When they reconnect, they rejoin at their previous data.
RaceCompetitor handles this automatically: disabling a GameObject disconnects the competitor by default.
// Disable the car's GameObject — automatically disconnects (default behaviour)
competitorGameObject.SetActive(false);
To disconnect manually (without disabling the GameObject):
RaceTimingManager.Instance.DisconnectCompetitor(competitorId);
Reconnect
Re-enabling the GameObject triggers reconnection automatically — no extra code needed.
// Re-enable the car — automatically reconnects if previously disconnected
competitorGameObject.SetActive(true);
To reconnect a competitor whose RaceCompetitor component is already in the scene:
var competitor = competitorGameObject.GetComponent<RaceCompetitor>();
RaceTimingManager.Instance.TryReconnectCompetitor(competitor);
Withdraw (permanent retirement)
A withdrawn competitor is permanently removed from live standings (but their data is retained for results display). Withdrawal cannot be reversed during a session.
RaceTimingManager.Instance.WithdrawCompetitor(competitorId);
To make a RaceCompetitor permanently withdraw when its GameObject is disabled (instead of the default disconnect behaviour), tick Withdraw On Disable in the Inspector, or set it in code:
GetComponent<RaceCompetitor>().WithdrawOnDisable = true;
Reconnection across scene reloads (network/persistent IDs)
By default RaceCompetitor uses Unity's InstanceID as its competitor ID, which changes every play session. For network multiplayer or scenarios where a player's car might be destroyed and respawned, set Persistent Id to a stable value (e.g. a network player ID):
// Assign before the competitor registers (e.g. in Awake or before SetActive(true))
GetComponent<RaceCompetitor>().PersistentId = networkPlayerId;
When the same PersistentId re-appears, the system automatically reconnects the competitor to their existing session data.
Listening to lifecycle events
var lapTimer = RaceTimingManager.Instance.LapTimer;
lapTimer.CompetitorDisconnected += e =>
Debug.Log($"Competitor {e.CompetitorId} disconnected");
lapTimer.CompetitorReconnected += e =>
Debug.Log($"Competitor {e.CompetitorId} reconnected");
lapTimer.CompetitorWithdrawn += e =>
Debug.Log($"Competitor {e.CompetitorId} withdrawn");
9) Performance Tuning (Breadcrumb Retention)
Sirocco Race Timing uses a breadcrumb trail — a rolling history of each competitor's track positions — to calculate live gaps and stationary detection. For most games the defaults work well, but you can tune the system if you need to trade memory for CPU or vice versa.
Inspector fields (quick tuning)
The two most commonly adjusted settings are exposed directly on RaceTimingManager:
| Inspector Field | Default | Effect |
|---|---|---|
| Standings Update Interval | 0.1s (10 Hz) | How often standings are re-sorted. Increase to reduce CPU cost; decrease for more responsive position display. |
| Stationary Speed Threshold | 3.0 m/s | Competitors below this speed are treated as stationary — gap times freeze to prevent inflation during pit stops or crashes. Does not affect lap or sector timing. |
Advanced tuning in code
For finer control, configure BreadcrumbRetentionConfig before the session starts and pass it when creating LapTimer. If you're using RaceTimingManager, the cleanest approach is to subclass it or hook into initialization.
The full set of options:
var config = new BreadcrumbRetentionConfig
{
// How many seconds of position history to retain per competitor.
// Must be >= your longest expected gap between leader and last place.
// Default: 120s. Increase for endurance races with very large fields.
RetentionWindowSeconds = 120.0,
// Minimum breadcrumbs to keep regardless of age.
// Prevents interpolation errors during slow laps or session start.
// Default: 10.
MinimumBreadcrumbCount = 10,
// How often gaps are recalculated (seconds).
// Default: 0.1 (10 Hz). Increase to 0.25–0.5 on low-end mobile.
GapUpdateIntervalSeconds = 0.1,
// How often standings are re-sorted (seconds).
// Default: 0.1 (10 Hz). Mirror of the Inspector field above.
StandingsUpdateIntervalSeconds = 0.1,
// Speed (m/s) below which a competitor is considered stationary.
// Default: 3.0 (~11 km/h). Mirror of the Inspector field above.
StationarySpeedThreshold = 3.0,
};
Recommended presets
| Scenario | RetentionWindow | GapUpdateInterval | StandingsInterval |
|---|---|---|---|
| Mobile (battery/thermal concern) | 60s | 0.25s | 0.25s |
| Default (desktop / console) | 120s | 0.1s | 0.1s |
| Large endurance field (60 cars) | 180s | 0.1s | 0.2s |
Note:
RetentionWindowSecondsshould comfortably exceed the maximum time gap between your leader and last-place competitor. For a 5-minute lap with a 60-car field, 120s is usually sufficient; for very long circuits or large grids you may need to increase it.
10) Pausing the Game / TimeScale
Sirocco Race Timing integrates seamlessly with Unity's internal clock system. The core LapTimer calculates elapsed times and gap metrics by relying on Time.timeAsDouble under the hood via the ITimeProvider interface.
Because of this, pausing the game via Time.timeScale = 0f automatically pauses all race timing.
- When
Time.timeScaleis set to0f, the internal clock effectively stops advancing. - Lap times, sector splits, and session progress freeze immediately.
- When you unpause the game (
Time.timeScale = 1f), timing picks up exactly where it left off. - The system prevents any artificial inflation of gap times or distance metrics while paused. There is no need to manually stop and restart the session.