Skip to content

Game-state websocket

Set BLUR_STATE_WS=<port> to serve the game-state feed on ws://127.0.0.1:<port>. Unset: no listener. Loopback only. Any number of clients; they only receive (anything they send is ignored). Messages are JSON text frames. A machine-readable JSON Schema (draft 2020-12) validates every message; this page is the primary explanation.

Every message is an envelope: {"type":"snapshot|change|event", "race_id":..., "race_ms":..., ...}.

Field Meaning
type snapshot (once, on connect), change (state key changed), event (one-shot)
race_id the race’s ID, a UUIDv7 the host mints when the race starts (local to this instance). Present only during a race, and on the race_result that ends it
race_ms the local racer’s elapsed race time in integer milliseconds (frozen once the player finishes). Present only during a race, once known

State is replaced by later values. On connect, one snapshot of all currently set keys:

{"type":"snapshot","state":{"screen":"mpmain","mode":"race","track":"Dubai","lobby":"hosting","players":3}}

Then one change per key change. value is native JSON (number, string, bool, array or object), never JSON inside a string. null means the key was cleared (absent from later snapshots):

{"type":"change","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":63210,"key":"lap","value":2}

Naming: snake_case; every time is an integer _ms; a racer index is racer (or a role name such as victim, attacker); an optional field is left out rather than null; powerup and mod names are lowercase icon names; mods is always an array. Racer indices and join_ref are local to each instance: match a LAN race across instances by event serial.

Keys: screen, mode (strings); race_type (the race mode, e.g. Multiplayer_Racing); track (debug tag), track_name, route_name (display strings, once resolved); lobby (none|hosting|joined); players (number); roster (see below).

Dev-build-only keys for scripts/; state::set drops them in release builds. Updated every frame; a key is cleared (null) when it does not apply:

Key Value
dev_menu_index number: the focused item’s 0-based index in the active screen’s list or carousel (GUI.CurrentList, GUI.carousel_id, GUI.MenuId, or a named *List gadget); null on screens with none
dev_menu_item string: the focused item’s name. On the carousels (main menu, multiplayer menu) it is the game’s branch name (Races, Stickers, Garage, Options, Quit, MpOnline, MpLan, …); elsewhere the first text of the list node when the game exposes one. null on lists whose rows the host cannot see (e.g. Options pages, LAN browser, lobby)
dev_lobby_state string: the game’s UIEnums.MpLobbyState name (e.g. WaitingForHost, Ready). Meaningful while lobby is not none; the game’s idle value is FindingPlayers
dev_loadout number: the current profile mod loadout
dev_loadout_lobby number: the current lobby mod loadout (the set a LAN/online lobby uses)

Race telemetry, set while a race is running and cleared (null) when it ends, except race_result and roster, which stay until the next race. Needs a connected client; it works in single-player, split-screen and network races:

Key Value
racer number: the local player’s racer index
lap, laps numbers: current lap (as the HUD shows it) and total laps
position, racers numbers: the player’s place (1 = first) and the number of ranked racers
standings array, best first, one object per racer (below)
race_result {"final":false,"completed":true,"standings":[...],"awards":[...]} when the player finishes, again with "final":true once every racer has finished. A race that ends after the start but before the player finishes (timeout, quit) sends "final":true,"completed":false with the last standings. The racers’ finish times are time_ms in standings. awards and the racers’ award_stats are added later, when the game’s awards screen opens (see below)

[{"racer":3,"position":1,"lap":2,"finished":false,"mph":112,"lap_ms":41200,"race_ms":131200,"dist":2.412,...},...]; positions are the ones the HUD uses. Identity (name, car, mods, local) is not here: join to roster on racer. About 5 updates a second.

Field Meaning
racer, position, lap, finished racer index, place, current lap (capped at laps), whether the racer has crossed the finish
mph speed in mph
lap_ms current lap so far; omitted once finished
race_ms completed laps + lap_ms
time_ms the finish time, only on a finished racer (the same as its final race_ms)
last_ms, best_ms last and fastest completed lap; absent until the racer completes a lap. best_ms is the game’s own best-lap value; last_ms is exact when the lap was a new best, otherwise derived from the lap counter changing (accurate to a frame or two); lap decreases are ignored
award_stats the game’s per-racer award stats, a snake_case stat name to the game’s raw number (top_speed, longest_drift, players_hit_with_bolt, …; all 48 MultiplayerAwardStat entries, see Awards). Multiplayer only, and absent until the game has tallied that racer: it fills each racer’s record when the racer’s race ends (the local racer when it finishes, remote racers when their record arrives), so mid-race standings carry none
dist race progress in laps, 3 decimals: completed laps + fraction of the current lap, so 1.5 is half way round the first lap (about 0.8 on the grid before the line)
gap_ms, int_ms racers behind the leader: time since the leader was at that racer’s distance (interpolated from the leader’s 5 Hz history), and time behind the car one place ahead (gap_ms difference). gap_ms is absent for the leader and until the leader has covered that racer’s distance; int_ms also needs the car ahead to have a gap (or be the leader); gap_ms is frozen once the racer finishes. Pausing the game does not grow gaps
totals the racer’s powerup totals so far (below). Present for every racer, every mode
powerups the held pickups from the racer’s queue: always 3 slot-ordered lowercase names (nitro, shock, shunt, bolt, barge, mine, repair, shield; unknown for any other kind), null for an empty slot, e.g. ["bolt","shunt",null]. The one place null is not “cleared”: a fixed 3-slot array where null is an empty slot. Absent until the racer’s queue has been read. The game lets the player pick which held pickup to fire, so slot order is the game’s queue order, not collect order. A Bribe racer’s starting powerup is not replicated: the owner’s instance shows it (as unknown for ~1 s, then its kind), other LAN instances never see it in the queue and only learn of it from an inferred collect when it is fired as a bolt, shock, shunt, barge, mine or shield (Nitro and Repair uses emit no event, so they are invisible there)

totals is counted from the feed’s own events, so every instance in a LAN race agrees. The per-kind maps (uses, collections, hits_dealt, hits_taken, hits_shielded, damage_dealt, damage_taken) are keyed by powerup name and only list kinds that happened; {} when none. The final values are in race_result.standings.

"totals":{"uses":{"bolt":3,"mine":1},"collections":{"bolt":1,"mine":1},"hits_dealt":{"bolt":2},"hits_taken":{"shunt":1},"hits_shielded":{},"damage_dealt":{"bolt":100},"damage_taken":{"shunt":100},"wrecks_dealt":0,"wrecks_suffered":1,"self_hits":0,"decoys_dropped":2,"decoy_hits_dealt":1,"decoy_hits_taken":0}
totals field Meaning
uses use events by this racer: a bolt volley counts 3, placing a mine 1
collections collect events for this racer, including inferred ones
hits_dealt, damage_dealt hit events where this racer is the attacker, and the health they removed (0 when shielded)
hits_taken, damage_taken hit events where this racer is the victim, and the health lost
hits_shielded hits taken that the racer’s shield absorbed (also counted in hits_taken, with 0 damage)
wrecks_dealt wrecks this racer’s powerups caused (the hit that took the victim from alive to wrecked)
wrecks_suffered times this racer was wrecked, by a powerup or by a collision (collisions are seen by the 5 Hz health poll)
self_hits hits on the racer by its own powerup. Not counted in the hits_* and damage_* maps
decoys_dropped decoy_drop events by this racer
decoy_hits_dealt, decoy_hits_taken decoy_hit events where this racer is the attacker / victim. Decoy hits are not counted in hits_*, damage_* or wrecks_*

Totals restart at 0 when a race ends, so powerups used just before the feed sees the race start still count.

Multiplayer races only (LAN, online, split-screen), where the game computes awards; race_result has no awards in single-player. The game shows them on the awards screen after the results, so race_result is republished with awards once that screen opens, on every instance and for all racers, local and remote. They are not available during the race. The game exposes an award’s value only as formatted text ("184mph"), so value is parsed from it; totals is separate.

The numbers behind the awards are read natively: each racer’s standings entry carries award_stats with every stat the game tracks, as raw game values ("award_stats":{"fastest_lap":106.356,"top_speed":115.687,"accuracy":24.138,...}; lap and time stats in seconds, speeds in mph, accuracy a percentage). Other stats use the game’s own units; the awards screen’s display rounds and reformats them. A stat appears once the game has tallied the racer, so award_stats shows up in standings as racers finish and is final in race_result; where the native record is unavailable, race_result falls back to the stat of each award the racer won, parsed from the awards screen. Zero is a real value, not an absence.

"awards":[{"award":"breakneck","racer":1,"stat":"top_speed","value":184,"unit":"mph","display":"184mph"}]

Field Meaning
award the award’s lowercase icon name (breakneck, crash_test_dummy). The awards lookup table is keyed slightly differently (crashtestdummy), so match with underscores removed
racer racer index
stat the game’s stat behind the award, snake_case (top_speed, players_hit_with_bolt). Absent when the award is not a plain stat (fan scoring, “most crashes”)
value, unit display parsed: a number, and the text after it if any (mph); m:ss.f is milliseconds with unit ms. Absent when display is not a number
display the amount text as the game shows it

A racer’s lap can go down during a race (the game’s own counter, e.g. after driving back over the start/finish line); it is a normal update, not the end of the race.

Client state: who is in the lobby or race, in the connect snapshot and published in the lobby and when the race loads. It stays set after the race so race_result can join to it.

[{"racer":3,"join_ref":2,"name":"alpha","car":"Audi_TT_Stock","mods":["stable_frame","safety_net","laser_sight"],"local":true,"ai":false},...]

Field Meaning
racer racer index (same numbering as standings). Absent in a lobby until the race loads and the game maps players to racers
join_ref the game’s per-session join reference. Absent for AI racers
name the player name (OS user name the game sees: blur unless BLUR_PLAYER_NAME=<name> or the persisted --nickname is set; use it to tell instances apart). Absent for AI racers and in single-player
car car icon name. In a lobby it is absent until the game’s car table has loaded (it fills in when the race loads)
mods equipped mod names (lowercase icon names), in slot order; empty for AI racers and when unknown
local this racer is the player on this machine. In a lobby it is false until the race loads
ai an AI-driven racer

In network races every human has name, car and mods; AI racers and single-player have none (single-player only adds car and mods for the local racer).

One-shot messages with "type":"event". They are not state: never in the connect snapshot, every one is delivered (identical events are not merged), and there is no change for them. The event name is event and its payload is under data. Clients should ignore unknown type/event values.

hit: a racer was hit by a powerup (single-player, split-screen and LAN; local, AI and remote racers). Self-applied pickups (Nitro, Repair) are use events, not hits. The envelope race_ms is the local racer’s clock when the hit arrived.

{"type":"event","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":63210,"event":"hit","data":{"victim":3,"attacker":1,"powerup":"bolt","damage":50,"shielded":false,"wrecked":false,"serial":412}}
data field Meaning
victim, attacker racer indices (same numbering as standings). attacker is absent when unknown. A racer can hit itself (own mine, own Shock dome)
powerup shock, shunt, bolt (one event per bolt, 3 per volley), barge, mine; unknown for any other kind
damage health removed: 50 for shock/bolt, 100 for shunt/barge/mine; 0 when shielded
shielded the victim’s shield absorbed the hit
wrecked the victim’s health is 0 after the hit
serial the game’s event serial; the same hit has the same serial on every LAN instance

A strike the game repeats with the same serial and victim is sent once, so every LAN instance reports the same hits. shielded and wrecked read the victim’s racer object from the 5 Hz scan; until the scan has found it (first 200 ms of a race) both are false. Collision wrecks produce no event.

use: a racer used a powerup. One per use: a bolt volley is 3 events, placing a mine is 1 (the mine hitting someone is a separate hit), and Nitro, Repair and Shield count too. The envelope race_ms is the local racer’s clock when it arrived.

{"type":"event","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":63000,"event":"use","data":{"racer":1,"powerup":"bolt","serial":411}}
data field Meaning
racer the racer index that used it
powerup lowercase powerup name
serial the game’s event serial; the same use has the same serial on every LAN instance

collect: a racer picked up a powerup. Inferred from the 5 Hz held-queue diff, so it arrives up to 200 ms late and a pickup used inside that window is missed. When a use shows a kind the racer was never seen holding, a collect with "inferred":true is sent just before that use, and the queue gaining that pickup later is not sent again. A Bribe grant is sent as a normal collect on the owner’s instance once its kind resolves (~1 s after the grant); other instances get none (see powerups). Only the first use of a never-held kind is backfilled, so later missed pickups of a kind already seen are not.

{"type":"event","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":62800,"event":"collect","data":{"racer":1,"powerup":"bolt"}}
data field Meaning
racer, powerup as in use
inferred true when backfilled from a use; absent otherwise

decoy_drop: a racer with the Decoy_Drop mod discarded a held pickup (Y or RB), leaving a fake of it on the track. Inferred from the 5 Hz held-queue diff, so it arrives up to 200 ms late: a held pickup that left the queue with no use just before it. serial is inferred (join ref << 24 plus the nth drop) and equals the later decoy_hit serial only for a racer that never fires; use racer + powerup to pair them otherwise.

{"type":"event","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":52000,"event":"decoy_drop","data":{"racer":1,"powerup":"barge","serial":33554434}}

decoy_hit: a racer drove into a decoy. Sent instead of hit (no damage or shield fields; the game’s own hit event carries the flag). powerup is the kind the decoy imitates, attacker the dropper.

{"type":"event","race_id":"0199f3a0-6d1c-7c3e-9a52-3b8f4e2d1a07","race_ms":52089,"event":"decoy_hit","data":{"victim":0,"attacker":1,"powerup":"barge","serial":33554434}}

lap is absent on the grid before the start.

Display names, icons and track images are not on the wire. They are repo data: JSON tables for cars (keyed by the car icon name), mods and powerups (lowercase icon names), tracks and routes (debug tags), and awards in examples/live-timing/data/, icons in examples/live-timing/icons/, and route images in examples/live-timing/tracks/. tools/lookup/gen.py writes the tables from a game install. The Lua patch’s lookup tables (join ref to racer, car and mod ids to icon names) are host-internal and not feed keys.

Try it: BLUR_STATE_WS=9000 ...blurhost then websocat ws://127.0.0.1:9000.

Live timing is an F1-style timing screen built on this feed. The hosted page replays a sample race in your browser with no game or Python. To watch a running game instead, serve examples/live-timing/ over plain http and open index.html?ws=<port>; see the README there.