F0 is a racing simulator where the cars are yours. The server runs the world, the track, and the physics. You bring an HTTP service that the server polls every tick to ask "what do you want to do next?". Your job is to write that service.
This document tells you everything you need to put a car on the grid and then everything you can do to make it actually fast.
The server needs a Postgres database and a JVM (Kotlin/JVM 21, Ktor 3).
Brings up Postgres and the app together:
docker compose up --buildThe app is then on http://localhost:8080.
./gradlew run only starts the app, so you need a Postgres running first. The
simplest way is to start just the database from the compose file:
docker compose up -d postgres # Postgres on localhost:5433
./gradlew run # app on http://localhost:8080The app reads its database connection from env vars, falling back to the local Postgres above if they're unset:
| Env var | Default |
|---|---|
DATABASE_URL |
jdbc:postgresql://localhost:5433/f0 |
DATABASE_USER |
f0 |
DATABASE_PASSWORD |
(see application.yaml / compose) |
Migrations are manual.
SchemaUtils.createonly creates missing tables; it never alters existing columns. After a schema change, droppgdata/(or run theALTER TABLEby hand) before starting.
A fresh server has no useful schedule, so nothing will run until you set one. Open the admin config page and pick the timings:
- Go to http://localhost:8080/weekend-config.html.
- Sign in when prompted with the admin basic-auth credentials (user
admin, password fromRouting.kt). - Hit a Quick Preset (e.g. Start from Now or Start in 5 minutes) to fill in the standard FP1 → FP2 → Qualifying → Race schedule, or set each phase's start time / duration (and the race lap count) by hand.
- Click Save Configuration.
The weekend advances through its six phases — PRESEASON → FP1 → FP2 → Qualifying → Race → Podium — based on these times. Drivers register during
PRESEASON; once FP1's start time passes, the world starts ticking and polling
your driver. You can re-open the page any time to reschedule.
Watch the action live at http://localhost:8080/track-viewer.html, and the final results at http://localhost:8080/podium.html.
A car that just rolls out of the pit and idles on track is enough to be a registered participant. You need three HTTP endpoints, all on one host:
POST /<your-route>/pit body: PitView → { "leavePit": true }
POST /<your-route>/drive body: DriverView → { "pedal": 1, "steer": 0, "enterPit": false }
GET /<your-route>/heartbeat no body → 200 OK
That's it. If you reply {"leavePit": true} to every /pit poll and anything
sane to /drive, you'll be on the track. If your handler throws or your service
times out, the server defaults to "stay where you are" and the race goes on — your last pedal is reused.
POST /driver once at startup. The server uses host to call you back on every
poll, so it must be reachable from the server.
{
"driverNumber": 17, // 1..99
"driverName": "Speedy", // 3..50 alphanumeric, not "admin"
"driverPass": "secret123", // ≥ 3 chars
"livery1": "#FF0000", // hex colour
"livery2": "#000000",
"engine": 4, // engine + breaks + steering MUST sum to 10
"breaks": 3,
"steering": 3,
"host": "http://my-host:9000/speedy"
}After registration the server starts polling host + /pit, /drive,
/heartbeat.
Your 10 points buy three properties via a curve, so the first point is worth more than the tenth:
| Stat | What it raises | Range |
|---|---|---|
engine |
Top-end power → acceleration & top speed | 550 kW → 850 kW |
breaks |
Braking friction coefficient | 1.2 → 2.2 |
steering |
Lateral friction → cornering speed | 1.4 → 1.7 |
Any allocation summing to 10 is legal. There is no single best build.
The world ticks every GameConfig.TICK_MILLIS (default 250 ms). On every tick
the server polls every active driver in parallel and waits up to 200 ms for
each response.
| When you are… | The server calls | You return |
|---|---|---|
| In the pit | POST /pit with PitView |
PitAction |
| On track | POST /drive with DriverView |
DriverAction |
| In a paused phase | GET /heartbeat |
200 OK |
/heartbeat fires during phases where the world is not ticking
(PRESEASON, between FP1/FP2 etc.). Treat the absence of /heartbeat as
your "the server has gone away" signal — it's pinged often enough to make a
disconnect obvious.
{ "pedal": 3, "steer": 0, "enterPit": false }pedal∈[-5, +5]. Positive = throttle (×0.2 each), negative = brake, 0 = coast.steer∈{ -1, 0, +1 }. Changes lane immediately by ±1. Other values are ignored.enterPittrueto head to the pit on the next tick (only honoured in phases that allow pitting).
Steering is applied once and reset to 0 after a successful lane change. You
must re-send steer: 1 each tick you want to keep moving. See §6 for the
anti-weaving penalty.
{ "leavePit": true }Returning false keeps you in the pit; the server will keep polling /pit
until you say true. On exit the server places you at lane +2, position
3600 m (start of the pit-exit window, length 150 m) at 100 km/h with pedal=2.
- Track: Zandvoort, 427 segments, each with a real-world arc length and signed radius. Negative radius = right-hander, positive = left-hander, very large absolute radius ≈ straight.
- Lanes: 5 lanes total, indexed
-2, -1, 0, +1, +2.Lane Role 0Racing line ±1Inner / outer racing lines -2Gravel (slow) +2Pit lane (gravel everywhere except the pit-exit window [3600, 3750]) - Same segment id on every lane. A given lateral position is segment 42 whether you're at lane −1 or +1; only the along-track distance differs.
The weekend runs through six phases in order. Each can be configured via
weekend-config.html.
| Key | Display | Ticks? | Collisions punish? | Notes |
|---|---|---|---|---|
PRESEASON |
Pre-season | no | — | Cars idle, registrations open |
FP1 |
Practice 1 | yes | no (1-tick flag) | Free practice |
FP2 |
Practice 2 | yes | no (1-tick flag) | Free practice |
Q |
Qualifying | yes | no (1-tick flag) | Best lap counts |
R |
Race | yes | yes | Real damage; lap target enforced |
P |
Podium | no | — | Results frozen |
In FP/Q a car-on-car contact briefly flags both drivers but neither is
relocated. In R it's a full crash for both — gravel, ~1 s stun, lost lap.
Every /drive poll receives this:
data class DriverView(
val order: Int, // your current race order
val driverNumber: Int,
val pedal: Int, val steer: Int, // your last *committed* inputs
val speed: Double, // km/h
val lane: Int, // current lane (-2..+2)
val crashed: Boolean, // true while you're stunned
val segments: Map<Int, Map<Int, Lane>>,
val cars: List<CarInView>,
)
data class Lane(val radius: Double, val distance: Double)
data class CarInView(
val order: Int, val driverNumber: Int,
val braking: Boolean, val speed: Double,
val lane: Int, // their lane
val distance: Double, // signed metres ahead, projected onto YOUR lane
)A rotated map keyed by offset from your current segment: keys run
-10..+20. So segments[0] is your current segment and segments[5] is five
ahead. The inner map is keyed by lane: segments[5][0] is the racing line of
the segment 5 ahead of you.
For each Lane you get:
radius— corner radius in metres on that lane. Use this for braking points. The bigger the absolute value, the gentler the corner (or a straight).distance— signed along-track metres from your current position to the start of that segment, measured on your lane and wrapped to[-halfLap, +halfLap]. Negative means it's behind you. At offset0it's the negative of how far you are into your current segment.
This is your lookahead. It's enough to plan an entire braking/throttle sequence to a corner that's, say, 6 segments away.
Every other car within the same segment offset window (-10..+20 segment ids)
shows up here.
laneis their lane (you can use it to plan an overtake on a different lane).distanceis signed and projected onto your lane, so you can compare directly against your ownsegments[*]distances. Negative = behind you.brakingis a leading indicator that they're slowing — useful for anticipating a slipstream gain or a same-lane closure.
Every successful lane change costs you 2 % of your current speed and locks your steering for 1 tick (you can't steer again until the tick after next). This was added so weaving back-and-forth to block overtakes isn't free. Lane changes are still strong — they just have a real price. Use them.
Cornering too fast for the corner's grip limit (v > cornerMaxSpeed(radius))
is a hard crash regardless of phase: you're relocated to a random gravel lane
(±2), zeroed to 0 km/h, and stunned for 1 s (≈4 ticks at TICK_MILLIS=250).
Your pedal and steer are forced to 0 on relocation.
Cornering speed:
v_max = sqrt( r · μ · m · g / (m − r · μ · β) ) if (m − r·μ·β) > 0
∞ otherwise (downforce dominates)
with r = lane radius, μ = lateral friction (your steering stat),
m = 740 kg, g = 9.81, β = 1.8 (downforce coefficient). Concretely: invest
in steering to corner faster, and brake into the corner so your speed at
entry is below v_max for that segment's radius.
If your motion ends within 5 m of another car on your lane, you collide.
In the Race phase this is a full crash for both of you. In FP/Q you both get
a one-tick crashed=true flag, you don't move, your inputs survive — but you
see the flag in the next DriverView, which is your cue to back off or
change lane.
Lanes ±2 cap your top speed at 30 km/h with an extra 30 m/s² decel
applied while above the cap. If you're not in the pit-exit window on lane +2,
you're on gravel.
The minimum driver from the TL;DR will plod around at low speed. Here's the rest of the toolbox.
segments[0..20][lane].radius lets you precompute, every tick, the next safe
speed for each upcoming segment. A robust loop:
- For every segment in
segments[0..N]on your current lane, computev_max(radius)using the formula above (or your stat-tuned approximation). - Walk back from the slowest upcoming corner, subtracting how much speed your
brakes can shed per tick (your
breaksstat × normal force × tickSeconds). - Pick the most restrictive
pedalthat keeps every future entry speed ≤ that segment'sv_max. Default topedal: +5if every corner is fine.
If another car on your lane is between 10 and 80 metres ahead, your effective drag area is reduced by up to 35 %. Closer = stronger; ≤10 m is the maximum benefit. Your top speed and acceleration both rise.
The slipstream isn't reported directly in DriverView, but you can derive it
from cars:
- Find any car with
lane == yourLane,distance > 0,distance < 80. - The closer they are, the more slipstream you get.
Strategic implications:
- On a long straight, sit 15–25 m behind a faster car, then jump to a free lane right before the corner.
- Don't change lane out of someone's tow on a straight unless you can make it pay back in cornering.
cars only covers the segment window around you. The server publishes the
rest of the world over REST — poll it from your driver to plan races, not just
laps:
| Endpoint | What you get |
|---|---|
GET /api/status |
Current phase key + timing |
GET /api/standings |
Order in the current phase |
GET /api/fastestLaps/{driver} |
Segment-by-segment speeds of that driver's best lap |
GET /api/podium |
Final podium + honorary mentions (after Podium) |
/api/fastestLaps/{driver} is gold: it's a target speed per segment lifted
from a real fast lap. You can use the leader's profile as your benchmark and
look for segments where you're underperforming.
The lane-change tax means weaving back-and-forth tick-after-tick isn't
viable. But a single well-timed lane move on the approach to a braking zone
is still very effective. Combine with cars[*].braking and cars[*].distance
to detect dive-bombs and pre-empt them.
FP1, FP2 and Q don't enforce car-to-car damage. Use them to:
- Try aggressive overtakes and see whether the flag fires (it will if you would've crashed in the race).
- Calibrate your braking model end-to-end against the real server, not your local sim.
A lap counts only when you cross segment 0 and your lapStats already
contains every segment id 0..426. If you crashed mid-lap and got relocated,
you may be missing segment ids; a return to a racing lane and a clean drive
to segment 0 is required for the next lap to count. On gravel (capped at 30
km/h) this is slow — the strategic answer is usually to recover to lane −1, 0
or +1 ASAP.
When you enterPit: true and re-launch from the pit, you start at lane +2,
position 3600 m, at 100 km/h with pedal=2. The pit-exit window is
[3600, 3750]. Outside that window, lane +2 is gravel — so plan to merge
back to lane +1 (or just steer −1) before you reach 3750 m, or you'll bog
down to 30 km/h.
| You want to … | File |
|---|---|
| Match the JSON shapes server-side | game/presentation/DriverView.kt, game/presentation/PitView.kt |
| Tune your AI against the real physics | game/physics/CarPhysics.kt |
| Understand crash & collision rules in detail | game/Driving.kt |
| See what counts as a lap | recordSegmentEntries in game/Driving.kt |
| Tick rate / timeouts / config | game/GameConfig.kt |
Good luck. See you on the grid.