How it works
SplitTime records the telemetry Forza Horizon 6 sends while you drive, works out where each lap or run starts and ends, and compares drivers' best times. The game sends physics and a few clocks. It does not send the track name, the event type, the official result or whether a lap was valid. Everything on this site beyond the raw numbers is inferred, and this page says how, and where the inference is currently wrong.
1. The pieces
Two processes share one SQLite file. The collector must stay up, because a packet that arrives while it is down is lost for good. The website can be rebuilt at any time.
SessionTracker per driver (app/recorder/laps.py) → buffered write every second/api/*. Asks the collector what is recording before any heavy write/ws/live, served by the collector directly- Lossless by design. Laps, routes and flags are an index over the stored packets. Deleting the index and rebuilding it from the packets gives the same answer, or a better one after a fix.
- One writer at a time. The collector waits at most 0.25 s for the database, then keeps its frames in memory (up to 3600) and retries on the next flush. A reprocess or import on the website is refused while any session is recording.
2. What the game sends, and what it leaves out
| Field | What it is | What the recorder uses it for |
|---|---|---|
IsRaceOn | 1 while driving, including free roam. 0 in menus and pause | Starting and ending sessions |
CurrentRaceTime | Event clock. Starts at 0 when the event loads | Splitting sessions, timing runs that have no lap clock |
LapNumber, CurrentLap, LastLap, BestLap | The game's own lap counter and lap clock. Dead (all zero) in some event types | Lap boundaries and lap times on circuits |
DistanceTraveled | Odometer along the route. Normalised: it reads about 5950 at the end of every completed lap or run, whatever the course's real length. Negative on the run-up before a start line | Aligning laps for comparison, detecting launch and finish, route length |
RacePosition | Place in the field. 0 in free roam | "Gridded": proof this is a race and not free roam |
| Position, yaw, speed | World coordinates in metres | Maps, route fingerprint, geometric lap detection |
| Acceleration, suspension, tyre slip | Per frame, per wheel | Contact, jumps, surface guess |
Not in the packet: track or event name, event type (race, Rivals, drift, drag), the result screen's time, a "lap invalid" or off-track flag, the driver's identity, other cars.
3. Sessions
A session is one continuous attempt at one event. Restarting an event makes a new session.
Attempts
Every started event is one attempt, however short. A session that ends with a timed lap or run is a finished attempt. One that ends without is given up: its packets go, but one small row stays with the driver, the car, where it started and how far it got. "Started" means a race from a grid, or a lap-timed event, whose odometer left the line. The route of a given-up attempt is worked out from its start position: the one recorded route that starts within 120 m, else the one catalogue course, else "Unknown route".
4. Laps on a circuit
Where the game runs its lap fields, the recorder trusts them:
LapNumbergoes up → the open lap is closed with the game's ownLastLaptime, and the next lap opens.LastLapchanges whileLapNumberstays put → the final lap of a race. The game stops counting at the finish line.- The lap clock starts counting after sitting at zero → the lap is re-anchored there (free-roam time attack).
- In Rivals the lap clock runs during the approach and resets at the start line. That reset is treated as the launch, not as a rewind.
While a lap is open the recorder keeps a trace of (distance into lap, elapsed time). The live delta is the current lap's time at this distance minus the session-best lap's time at the same distance. Comparing by distance, not by clock, is what makes two laps comparable corner by corner.
5. Finishing an event
This is the hardest part and the source of most faults. The game ends an event without
incrementing LapNumber, and a sprint, drag or touge run never has a lap boundary at
all. The recorder looks for a finish in this order. Rungs 1 to 3 are checked on every packet;
rungs 4 to 6 only when the stream has stopped.
Where the clock starts for a run
"Launch" is the packet where the odometer first reaches 1.0. The code assumes that is the moment the lights go out on the grid. On the gridded sprints and drag runs recorded so far, the grid sits at a negative odometer reading (−70.8 on one sprint), so the odometer reaches 1.0 at the start line, not on the grid. Recorded run times are therefore line-to-line. See fault F5.
6. Events with no lap clock (World Time Attack)
Some events send no lap fields at all. Laps are then found from position alone.
7. Lap flags
| Flag | How it is detected | Effect |
|---|---|---|
rewind | The lap clock falls more than 0.5 s below its highest reading while the odometer is not increasing | Cannot be a best |
contact | Ground-plane acceleration above 45 m/s² that is also an impulse: a rise of 30 m/s² within one frame, or a peak above 98 m/s². Ignored while airborne and for 0.35 s after landing | Cannot be a best |
cutoff | The time was taken from the last packet, with no finish signal (rungs 4 and 5) | Shown as "est."; counts only if the full route was covered |
Flags can be corrected by hand on the Analysis page. Corrections are stored separately, keyed by time, and survive a reprocess.
8. Routes and their names
The first timed lap of a session produces a fingerprint: start position, length on the odometer, and the width and depth of the ground covered. It is matched against known routes.
Because the odometer is normalised to about 5950 on every course, the length test separates nothing among completed runs. Start position and span do the work. The length test matters in one case only: a run that stopped short. Such a run is no longer timed (faults F3 and F4), so it no longer reaches this step.
9. Sittings
Restarting an event creates a new session, so an evening on one sprint arrives as many one-run sessions. When a session closes, finished sessions with the same driver, route and car, each starting within 2 hours of the one before, are grouped into a sitting. A sitting is an index entry: no lap or packet is moved. A session someone takes out of a sitting is never regrouped. Bests never depended on this; it only tidies the lists.
10. Which time counts as a best
app/rivalry.py). The full-route test applies only to cutoff times; a time with a finish signal is assumed to be a finished run. Fault F4 was a case where that assumption was false.This is the closest available match to the game's Rivals rule. The game's own verdict is not in the packet, and leaving the track is not detected at all.
11. What each page reads
| Page | Source | Notes |
|---|---|---|
| Live | The collector's socket | Every packet as it arrives, plus the live delta |
| Analysis | Sessions, laps and stored packets | Lap tables, maps, traces; rename, flag, exclude, reprocess, delete |
| Rivalry | /api/rivalry | Every lap with the verdict from section 10, which reads each lap's last packet once; the page only sorts and filters |
| Stats | /api/stats | Thirteen sections computed from the session, lap, route and attempt tables. It reuses the Rivalry verdicts, so it reads the last packet of each lap once (then cached) for the full-route test. No statistic scans a whole trace yet |
| Replay | /api/laps/{id}/data, /api/routes/{id}/corners, /api/corners/compare | Position and speed per packet for up to six laps, played against each lap's own clock. Corners are placed by route position; with two laps the comparison adds each corner's time difference and minimum speeds |
12. Where it fails
For a worked example of a recording that looked broken but wasn’t, and of what truly can’t be recovered, see Three laps found.
Each of these is a property of the software, found in real recordings. The figures are from the captures that exposed them. "Fixed" means fixed in the code: sessions recorded before a fix keep their old result until they are reprocessed.
| Fault | Seen as | Cause | State | |
|---|---|---|---|---|
| F1 | A complete run is deleted | A 155 s gridded sprint, odometer +5952, discarded with no timed lap | The race clock paused at 8.6 s before the run. Rung 3 latched "finished", which made the run time under 10 s and switched rung 5 off | fixed The latch is undone when the clock resumes. A run already deleted cannot be recovered |
| F2 | One circuit becomes two routes | The same circuit stored with length 5945 on one row and 6275 on another | A geometric lap is closed only after the car leaves the 60 m circle, and the trace past the line was counted in the lap's length. 6275 is outside the 5% tolerance | fixed The trace is cut at the crossing |
| F3 | An abandoned run got a time and its own route | A gridded sprint that stopped at odometer 4080 (69%) while rolling at 22 km/h, shown as an unnamed route | Rung 5 treated "gridded, over 500 units, still moving faster than 5 m/s" as a finish at the line. A driver quitting while rolling met all of it. The short length then failed the 5% test, so a new route was created | fixed Rung 5 now needs the full route |
| F4 | An abandoned run got a confirmed time | The stream paused 5.5 s at odometer 2483 (42%) with the car at 1.3 m/s, then the odometer read 0 with the car in the same place. Also shown as an unnamed route | Rung 2 called any hard reset of the odometer a finish. It checked that the car did not teleport, not that it reached the end. Because the time was "confirmed", the full-route test in section 10 was skipped | fixed For gridded runs, rung 2 now needs the full route. Runs with no lap clock (section 6) are unchanged |
| F5 | Sprint and drag times are probably short | A drag run recorded as 14.449 s while the game's own CurrentLap field reads 16.7 s in the last packet. A sprint recorded as 2:52.7 against about 2:55.1 on the game clock | The clock is started when the odometer reaches 1.0, which on these events is the start line, not the grid (section 5). The gap is the time from the grid to the line | open Not yet checked against a result screen |
| F6 | A real course has no name | Complete runs on a dead-straight 1 km strip, about 14.4 s each, shown as an unnamed route | The catalogue lists 77 courses and that strip is not among them | open Needs the in-game name |
| F7 | Sessions are deleted for reasons nobody can reconstruct | Sessions of 125 s and 184 s, not gridded, lap clock dead, discarded | Unknown, because a discarded session's packets are deleted with it. Possibly drift zones, which the recorder has never seen. The "keep discarded" setting exists to capture such a session once | open |
| F8 | Event types are counted together | Race, Rivals and others share one best per route and class | The event-type guess rests on two signals and one capture: a position worse than 1st means a race; a lap-clock reset at the start line means Rivals; otherwise unknown | open By decision, until more modes are captured |
| F9 | "Valid" is narrower than the game's rule | No recorded case yet | Cutting a corner or leaving the track sets no flag. Only rewind and contact are detected | open |
| F10 | Shared replay links can break | After a reprocess, a session's five laps had new ids | A reprocess deletes and recreates lap rows. Replay and compare links carry lap ids | open |
| F12 | A very quick restart is not a separate attempt | No recorded case yet | A session is split when the race clock jumps back by more than 10 s. A restart sooner than that stays in the same session, so it counts once. A rewind near the start looks similar | open Needs a capture of one |
| F13 | Given-up attempts at events with no lap clock are not counted | No recorded case yet | With no grid position and no lap fields, an abandoned run is indistinguishable from free roam | open |
| F11 | Packets can be forged | None seen | UDP has no login. Whoever sends to a driver's port is that driver | accepted |
How F3 and F4 produced a fake route
13. What might be fixed
- Done: require the distance before calling it a finish (F3, F4). A gridded run is timed only when the odometer has reached the full-route reading (98.5% of 5950), in rungs 2, 3 and 5. A run that stops short keeps its packets but gets no time and no route, and a session with no timed run is discarded like any abandoned event. Old recordings change only when they are reprocessed. Still open: the 5950 figure is measured, not documented, and the test is not applied to events with no lap clock, whose odometer at a finish has not been measured.
- Settle the clock origin (F5). Compare one result-screen time with the recording. If the game counts from the grid, time runs from the first packet where the race clock moves (or speed leaves zero) and reprocess the sprints. The replay's alignment follows the same clock.
- Name what the catalogue lacks (F6). Rename the route on the Analysis page, and add the entry to the catalogue so a fresh install recognises it.
- Capture each event type once (F7, F8). With "keep discarded" switched on, one drift event, one Rivals run and one race on the same route give the data needed to tell them apart, or to show that they cannot be told apart from telemetry.
- Approximate off-track (F9). The packet's driving-line field saturates at ±127 when the car is far from the course. A sustained saturation could set an "off course" flag. It would be a second approximation of the game's rule, not the rule.
- Give laps a stable identity (F10). Address a lap by session and time anchor, as manual edits already are, so links survive a reprocess.
- Statistics from packets. Top speed, air time and time on throttle need a pass over the stored packets, cached per lap.
14. Thresholds
Every number above is a constant in the code, calibrated on real recordings.
| Constant | Value | Meaning |
|---|---|---|
RACE_OFF_GRACE | 15 s | Race-off or silence before a session ends |
RACE_TIME_RESET_JUMP | 10 s | Backwards jump of the race clock that splits a session |
RT_FREEZE_SECONDS | 1.5 s | Frozen race clock that marks a finish |
FINISH_MIN_RT | 5 s | Freezes and resets before this are ignored |
PTP_MIN_RUN_TIME | 10 s | Shortest run that is believed |
WTA_MIN_LAP_DIST | 500 | Odometer units a run or geometric lap must cover |
WTA_DIST_COLLAPSE | 1000 | Single-frame odometer drop that counts as a reset |
POINT_FINISH_DIST | 200 | How close to zero the reset must land |
WTA_ARM_DIST / WTA_CROSS_DIST | 120 m / 60 m | Leave this far, then return this close, for a geometric lap |
WTA_TELEPORT_JUMP | 250 m | Single-frame move that means fast travel |
FINAL_LAP_DIST_FRACTION | 0.97 | Share of a typical lap that makes a cut-off lap the final lap |
REWIND_TIME_JUMP | 0.5 s | Lap clock running backwards |
IMPACT_ACCEL / IMPACT_JERK / IMPACT_PEAK | 45 / 30 / 98 m/s² | Contact: threshold, one-frame rise, stand-alone peak |
ROUTE_START_RADIUS_M | 120 m | Route match: start position |
ROUTE_LENGTH_TOLERANCE | 5% | Route match: odometer length |
ROUTE_SPAN_TOLERANCE | 15% | Route match: width and depth covered (50 m floor) |
FULL_ROUTE_UNITS | 5950 | Odometer reading at the end of any completed route; a gridded run, and any cutoff time, needs 98.5% of it |
AUTO_GROUP_GAP_S | 2 h | Longest gap between attempts in one sitting |
Lap and finish detection: app/recorder/laps.py. Storage, routes and sittings:
app/recorder/store.py. Best-lap rule: app/rivalry.py. Catalogue: app/tracks.py.
Event-type guess: app/eventtype.py.
15. Thanks
SplitTime is built on LapScope by Erdem Darcan. The packet parser, the recorder, the lap and finish detection described above, the route fingerprint, the track catalogue and the Live and Analysis pages are his work, tuned against a great many real captures. SplitTime adds a second driver, the Rivalry, Stats and Replay pages and the rules for comparing drivers. Thank you, Erdem. He announced LapScope in this post on r/ForzaHorizon.
Corner detection is adapted from Forza-6-telemetry by ClickClickMedia. Both projects are MIT-licensed. The faults listed on this page are about SplitTime as it runs here; several come from event types LapScope had not yet seen.