Skip to content

Network Integration Guide

Sirocco Race Timing is designed with a clear separation between its pure C# timing logic (the Core layer) and Unity integration (the Adapter layer). This architecture makes it straightforward to integrate with any multiplayer networking stack (Unity Netcode for GameObjects, Mirror, Photon Fusion, Fish-Net, etc.).

This guide explains how a multiplayer engineer can feed server-authoritative data into the timing system, ensuring accurate lap times, sector crossings, and standings calculations in a networked racing game.

Entry Points

The Core LapTimer class provides discrete entry points for feeding simulator or network state into the timing system. All methods require a competitorId to identify which competitor the data belongs to.

UpdatePosition

The primary method for high-fidelity timing with sub-tick interpolation.

public LapCrossingResult UpdatePosition(
    int competitorId,
    PositionSample current,
    PositionSample previous,
    double trackLength)
  • competitorId: The registered competitor's unique identifier.
  • current: The position sample from this tick.
  • previous: The position sample from the last tick. The system interpolates between these two samples to determine the exact sub-tick moment a lap or sector line was crossed.
  • trackLength: Total track length in meters. Used to convert ratios to distance for gap calculations.
  • Returns: A LapCrossingResult struct indicating whether a lap crossing was detected, its direction, and the interpolated crossing time.

Lap crossing detection: When current.Ratio wraps (e.g., 0.98 → 0.02) and the ratio delta exceeds 0.5, the system interpolates between the two samples to find the exact crossing time at the start/finish line.

Preconditions: The competitor must be registered, the session must be in Running state, and the competitor must be Active (not disconnected). If any precondition fails, the call is silently ignored and returns CrossingDetected = false.

TriggerSector

Used for explicit sector crossings, typically when the server dictates that a competitor crossed a sector line.

public void TriggerSector(
    int competitorId,
    int sectorId,
    double? explicitTime = null)
  • competitorId: The registered competitor's unique identifier.
  • sectorId: 1-based sector index. Must be in range [1, sectorCount].
  • explicitTime: If provided, records the crossing at this specific server-synchronized time instead of querying ITimeProvider.GetTime(). This is critical for server-authoritative race resolution where the server knows the exact crossing time.

UpdateDistance

A simpler alternative when track-projected ratios and segment indices are unavailable.

public void UpdateDistance(
    int competitorId,
    double distance,
    double x, double y, double z)
  • competitorId: The registered competitor's unique identifier.
  • distance: Total distance traveled in meters (must be monotonically increasing).
  • x, y, z: World position coordinates (as double). Used for breadcrumb recording and Euclidean gap calculations.

Trade-offs vs UpdatePosition:

  • No sub-tick interpolation for start/finish line crossings (lap detection must be handled externally via TriggerLap).
  • No micro-sector calculations for the delta bar.
  • No ratio-based gap calculations — falls back to distance-based breadcrumb interpolation only.

Use this path when your network only syncs world position and distance, without track spline data.

Data Types

PositionSample

A mutable struct with public fields. All numeric types are double (not float), matching the Core layer's precision requirements.

public struct PositionSample
{
    public double Time;          // Server-synchronized time of this sample
    public double Ratio;         // Track progress ratio (0.0 to 1.0)
    public int SegmentIndex;     // Current track spline segment index
    public int CompletedLaps;    // Number of full laps completed at this sample
    public double X;             // World position X
    public double Y;             // World position Y
    public double Z;             // World position Z
}

Field guidance for network consumers:

Field Source Notes
Time Server-synchronized clock Must be monotonically increasing. See Timing Assumptions.
Ratio Server's track projection Normalized 0.0–1.0 along the racing line. Wraps from ~1.0 back to ~0.0 at the start/finish line.
SegmentIndex Server's spline evaluation Index into the track's spline segments. Used for segment-walking distance calculation.
CompletedLaps Server's lap counter Drives lap crossing detection. Must increment when Ratio wraps.
X, Y, Z Server's authoritative position Used for breadcrumb world positions and Euclidean fallback calculations.

A convenience constructor is available:

new PositionSample(time, ratio, segmentIndex, completedLaps, x, y, z)

ITimeProvider

Allows the timing Core to use your server's clock instead of Unity's Time.timeAsDouble.

public interface ITimeProvider
{
    double GetTime();
}

Constructor injection: Pass your implementation when creating the LapTimer:

var timeProvider = new ServerSynchronizedTimeProvider();
var config = new BreadcrumbRetentionConfig();
var lapTimer = new LapTimer(timeProvider, config);

The optional BreadcrumbRetentionConfig parameter controls breadcrumb retention, standings throttling, and gap update intervals (see Timing Assumptions).

Implementation guidance: Your ITimeProvider should return a network-synced time adjusted for latency. If the server says a lap was completed at t=124.5 and the client receives it later, the synchronized time provider ensures recorded history is consistent across all clients.

Timing Assumptions

If integrating network data, there are invariant rules you must enforce. Violations are not detected at runtime (for performance reasons) — they silently corrupt data.

Time Monotonicity

Time must never flow backward.

  • If a PositionSample.Time is older than the previous sample's time, speed and gap calculations will produce incorrect results.
  • When timeDelta <= 0, the speed calculation defaults to 0.0, which means the breadcrumb system treats the competitor as stationary.
  • Network guidance: Discard or reorder out-of-sequence packets before feeding them into the timing system. The Core layer does not buffer or reorder inputs.

Distance Monotonicity

Total distance must always increase.

  • Breadcrumb interpolation assumes later entries have greater distance values.
  • A network teleport that moves a competitor backward on track will corrupt gap calculations for that competitor.
  • Network guidance: If your server performs rollback/reconciliation, recalculate distance from the corrected position rather than feeding a decreased value.

The Core uses a breadcrumb trail per competitor for gap interpolation.

  • What's stored: Each breadcrumb records (distance, racingTime, x, y, z).
  • Racing time vs wall-clock time: Breadcrumb timestamps use "racing time" — time that freezes when the competitor's speed falls below StationarySpeedThreshold (default: 3.0 m/s). This prevents gap inflation during pit stops or when a car is temporarily stationary. Racing time is calculated internally from consecutive UpdatePosition/UpdateDistance calls.
  • Retention window: Breadcrumbs older than RetentionWindowSeconds (default: 120 seconds) are pruned, with a minimum of MinimumBreadcrumbCount (default: 10) always preserved.
  • Network implication: Even if your network tick rate is lower than the local physics rate, the breadcrumb system still works — it interpolates between whatever samples it has. Lower tick rates mean less precise gap interpolation, but the system degrades gracefully.

Update Frequency Tolerance

The timing system is designed to accept inputs at any reasonable frequency.

  • Standings throttling: The RaceDirector throttles standings recalculations independently. Default intervals:
  • Standings sort: every 0.1 seconds (10 Hz)
  • Gap recalculation: every 0.1 seconds (10 Hz)
  • Decoupled from input rate: You can feed position samples at your network tick rate (e.g., 20 Hz) independently of Unity's FixedUpdate rate (e.g., 50 Hz). The throttling ensures that even at 50 Hz input, standings are only recalculated at 10 Hz.
  • Configurable: Adjust via BreadcrumbRetentionConfig:
var config = new BreadcrumbRetentionConfig
{
    StandingsUpdateIntervalSeconds = 0.2,  // 5 Hz standings
    GapUpdateIntervalSeconds = 0.1,        // 10 Hz gaps
    RetentionWindowSeconds = 120.0,        // 2 minutes of breadcrumbs
    StationarySpeedThreshold = 3.0         // m/s
};

Adapter Pattern

Instead of modifying the timing library, implement a NetworkRaceCompetitor MonoBehaviour in your own project. This acts as a bridge between your network state and the Core logic, following the same pattern as the built-in RaceCompetitor.

Skeleton: NetworkRaceCompetitor

using UnityEngine;
using SlowToast.RaceTiming.Core;
using SlowToast.RaceTiming.Core.Models;

/// <summary>
/// Bridges network-replicated state into the Sirocco Race Timing Core.
/// Lives in YOUR project, not in the timing library.
/// Replace NetworkBehaviour with your netcode framework's base class.
/// </summary>
public class NetworkRaceCompetitor : MonoBehaviour
{
    [SerializeField] private int _competitorId;
    [SerializeField] private string _driverName;

    private LapTimer _lapTimer;
    private PositionSample _previousSample;
    private double _trackLength;
    private bool _initialized;

    /// <summary>
    /// Call this when the network entity spawns and the timing system is ready.
    /// </summary>
    public void Initialize(LapTimer lapTimer, double trackLength)
    {
        _lapTimer = lapTimer;
        _trackLength = trackLength;

        // Register with the Core timing system
        _lapTimer.RegisterCompetitor(new Competitor
        {
            Id = _competitorId,
            Name = _driverName
        });

        _initialized = true;
    }

    /// <summary>
    /// Call this when your network layer delivers a new authoritative state update.
    /// </summary>
    public void OnServerStateReceived(
        double serverTime,
        double trackRatio,
        int segmentIndex,
        int completedLaps,
        double x, double y, double z)
    {
        if (!_initialized) return;

        var current = new PositionSample(
            time: serverTime,
            ratio: trackRatio,
            segmentIndex: segmentIndex,
            completedLaps: completedLaps,
            x: x, y: y, z: z
        );

        // Feed both current and previous sample for sub-tick interpolation
        _lapTimer.UpdatePosition(_competitorId, current, _previousSample, _trackLength);

        _previousSample = current;
    }

    /// <summary>
    /// Call this when the network client disconnects.
    /// Session data is preserved for potential reconnection.
    /// </summary>
    public void OnNetworkDisconnect()
    {
        if (!_initialized) return;
        _lapTimer.DisconnectCompetitor(_competitorId);
    }

    /// <summary>
    /// Call this when a previously disconnected client rejoins.
    /// Reactivates timing updates and resets gap tracking timestamps.
    /// </summary>
    public void OnNetworkReconnect()
    {
        if (!_initialized) return;
        _lapTimer.ReconnectCompetitor(_competitorId);
    }
}

Competitor Lifecycle

Map your network events to the Core lifecycle methods on LapTimer:

Network Event Core Method Behavior
Player joins / race starts RegisterCompetitor(Competitor) Creates session data. Must happen before any position updates.
Player disconnects DisconnectCompetitor(int competitorId) Marks as disconnected. Session data (laps, times) is preserved. Position updates are silently ignored.
Player rejoins ReconnectCompetitor(int competitorId) Reactivates the competitor. Gap tracking timestamps are reset to prevent stale calculations.
Player permanently leaves WithdrawCompetitor(int competitorId) Permanently removes the competitor. All session data is deleted.

Important: Competitors must be registered before receiving any position or sector updates. Calls to UpdatePosition, TriggerSector, or UpdateDistance for unregistered competitors are silently ignored.

Responsibility Boundary

Timing Library Owns

  1. Lap and sector timing — sub-tick interpolated crossing times, split times, cumulative times.
  2. Gap calculations — breadcrumb-based track intervals, distance gaps, and time gaps between competitors.
  3. Standings — position sorting respecting laps completed, distance, and penalties.
  4. Penalties — time penalties, disqualifications, lap invalidation, and penalty-aware ranking.
  5. Best times — personal best and session best tracking for laps and sectors (excluding invalid laps).

Network Layer Must Own

  1. Position authority — the server decides where cars are, resolving physics interactions and collisions.
  2. Clock synchronization — aligning client and server clocks so that timestamps from ITimeProvider are meaningful and comparable across machines.
  3. State reconciliation — handling out-of-order packets, deduplication, and rubber-banding. The timing system expects clean, ordered inputs.
  4. Interpolation / prediction — smoothing visual car movement between network ticks is a rendering concern, not a timing concern.
  5. Anti-cheat — the timing library does not validate that position data is physically plausible. Verifying that distance correlates to elapsed time, or that sector triggers aren't fabricated, is the server's responsibility.