LAN multiplayer
Status 2026-10-17, recomp engine. Online play (docs/online-server-spec.md) shares the peer transport but not discovery.
LAN (“SystemLink”, UIEnums.GameMode.SystemLink) is peer-to-peer with no server. It works with no account and with the online service off (BLUR_ONLINE_URL=off). Native builds speak the game’s own protocol, so they should interoperate with Windows/Wine peers. That has not been tested yet.
Facts come from two- and three-instance captures on one Mac (BLUR_NETTRACE=2 BLUR_DWLOG=1) unless marked static (decompiled Blur.exe / Lua) or inferred.
Player flow
Section titled “Player flow”- Start → login screen (Esc = Skip) → “features unavailable” warning (Enter) → Multiplayer → local area network (
Screens/Multiplayer/MpLan.lua). - Entering MpLan starts a search immediately; nothing found → “No LAN games found”.
- Browser columns: Host Name / Players / Mode (e.g.
blur 1/10 Skirmish Racing). Keys: Enter = Join, Shift = Refresh, Ctrl = Host Game, Esc = Back. - Host Game:
MpModeSelect→MpPlaylists→MpLobby. Lobby shows1/10 players, creator tagged[Host]. - Join: client lobby shows
2/10, “Get Ready: Waiting for host”. - Host: F1 = Start Game (Enter = Get Ready). Everyone loads into the race.
Who is host
Section titled “Who is host”- The instance that creates the session (Ctrl = Host Game) is host.
- Only the host answers discovery and only the host starts the race. Clients wait.
- Max players comes from the playlist (10 for the stock ones).
CreateLanServeraccepts 1–20 (static0xc18b71). - Host leaves, 2 players (observed, host killed mid-race): within ~5 s the client shows “PLEASE WAIT — Disconnecting”. It loads back to MpMain → MpLan, and the search finds nothing. Demonware just unregisters the session key. With one player left there is no migration.
- Host leaves, 3+ players → host migration (static; live test blocked, see Testing). Migration is generic session code with no game-mode check, so it applies to SystemLink:
- A player is dropped after 4.0 s of silence if you are host, 6.0 s otherwise (
0xb3f940). - If the dropped player is the host, the connected player with the lowest slot/join index (inferred: record byte +0x11c) is elected (
0xb367b0). - The lobby shows
MP_HOST_MIGRATING(IsSessionMigrating). - The new host sends session message
0x0bwith the remaining player IDs and settings (0xb368b0). - Once migration finishes, the new host starts answering discovery (
0xb34310). - If only one player remains, an event fires (hash
0xa219306f, probably “not enough players”).
- A player is dropped after 4.0 s of silence if you are host, 6.0 s otherwise (
| Port | Proto | Bound | Use |
|---|---|---|---|
| 50001 | UDP, 0.0.0.0 |
at boot | discovery: listen + send browse broadcasts |
| 3074 | UDP, 0.0.0.0 |
entering multiplayer | peer socket: address probes, DTLS, lobby/race data. Same as online P2P |
| 3075 | UDP | only if 3074 bind fails | game’s fallback for the peer socket |
Both ports are fixed in the game. For firewalls, allow inbound UDP 3074 and 50001, including broadcast. The 3074 socket is bound twice back to back on the same fd; both succeed.
Discovery protocol
Section titled “Discovery protocol”Blur’s own code (no bdLAN* classes). One socket on 50001 handles both sides: the pump 0xb3fcc0 receives requests and replies and builds replies; 0xb41010 builds requests.
Lua bindings (static, table 0xee4cf0):
NetRace.EnumerateLanGames0xc182e0CheckLanServerNetworkVersion0xc18360CreateLanServer0xc18b00JoinLanServer0xc18bf0IsSessionMigrating0xc15fb0- also
IsEnumeratingLanGames,NumLanServersEnumerated,GetLanLobbyList
BE = big-endian (htonl); everything else is little-endian.
Request: 24 bytes from the 50001 socket to 255.255.255.255:50001. Sent once on entering MpLan and once per Refresh, with no periodic resend.
| Off | Bytes | Field |
|---|---|---|
| +00 | 0f000000 |
u32 BE network version 0x0f000000 |
| +04 | 0000002c |
u32 BE title value (meaning unknown; receiver drops on mismatch) |
| +08 | 01000000 |
type 1 = request |
| +0c | 8 B | random nonce |
| +14 | 01000000 |
slots needed (party size) |
Host answers only if all of these hold (0xb3fd66):
- type is 1
- it is hosting
- the request is exactly 24 bytes
- open slots ≥ slots needed
So full games are invisible.
Reply: 160 bytes, unicast to the requester’s source address:port (built 0xb3fd92–0xb3fe77). The client keeps a reply only if all of these hold:
- it is exactly 160 bytes
- the nonce matches
- the client is still browsing
- fewer than 10 servers are listed already
Layout decoded statically and confirmed byte-for-byte against a full capture (out9):
| Off | Captured | Field |
|---|---|---|
| +00 | 0f000000 |
network version (as request) |
| +04 | 0000002c |
title value |
| +08 | 02000000 |
type 2 = reply |
| +0c | 3f8f66df936453b0 |
echoed nonce |
| +14 | 380f1601 |
never written: stack garbage |
| +18 | 9ef9f2d8 00000000 |
host player ID (u64, low half from Demonware 0xd8ab80) |
| +20 | bcfc71d079141b82 00f85020 05000000 |
host player record (56 B): 8-byte ID, two u32s… |
| +30 | "blur" UTF-16LE, zero-padded |
…gamertag (browser’s Host Name)… |
| +50 | 0000 5020 00000001 |
…record tail (unknown) |
| +58 | 00… |
8 B, unknown (zero) |
| +60 | e2cf6e9ec2993320 |
bdSecurityID, 8 random bytes |
| +68 | 8e29be8648c7670bac41c18ec3673dbd |
bdSecurityKey, 16 random bytes |
| +78 | c0a8b102020c 647e3913020c c0a84013020c 00ff00ff0000 01 |
host bdCommonAddr, 25 B: 192.168.177.2, 100.126.57.19, 192.168.64.19 (all :3074), public unset, NAT type 1 |
| +91 | 0a |
u8 max players (10) |
| +92 | 01 |
u8 current players |
| +94 | bdcd64be |
u32 event_id |
| +98 | 0b00 |
u16 playlist version (11) |
| +9a | 00 |
u8 flags: bit1 = custom race |
| +9b | … | unused (garbage) |
- Session key in clear. The DTLS session’s
bdSecurityID+bdSecurityKey(made byinitKey0xd09e80) go out in this reply. Joining copies +0x58..+0x77 into the session and registers the key (0xb3b360). Both peers log exactly these values (“Putting bdSecurityID/Key”), and the ID ends the DTLS Init/CookieEcho/CookieAck. Anyone on the LAN who can see the reply can derive the session key. That is acceptable for LAN, but worth knowing. bdCommonAddr(serialize0xcdc940, size0x19):- Layout: 3 local addresses × 6 B, then the public address (6 B), then 1 NAT-type byte.
- Each address is a 4-byte IPv4 in network order plus a 2-byte port, little-endian in the traces (
020c= 3074). Unused slots are zero. - The client probes every non-zero local address, which is how it learns the host’s interfaces.
- Only the first 3 local addresses are sent. In the capture the real LAN address (192.168.64.19) was the third slot, behind a bridge and Tailscale. A fourth interface would push it out, and joins would fail (inferred).
- Version check (
CheckLanServerNetworkVersion):- Result 1 compares +00 with
0x0f000000: 0 = same, 1 = host newer, 2 = host older. - Result 2 compares +0x98 with the local playlist version: 0 = same, 1 = host newer, 2 = host older, 3 = no such playlist.
- Mismatches show
eJoinErrorPlaylistVerHost/eJoinErrorPlaylistVerClient. JoinLanServerrefuses unless +00 is exactly0x0f000000. It returns{start, customrace, event_id}from +0x9a/+0x94.
- Result 1 compares +00 with
- Every instance receives every broadcast, its own included, via the directed-broadcast loopback.
Join protocol
Section titled “Join protocol”All traffic is on UDP 3074 ↔ 3074. Byte 0 is the packet type and byte 1 is the version (2), Demonware framing.
- Address probes (
0d, 29 bytes): the client probes the host’s port 3074 on every host IPv4 address, LAN and VPN alike (e.g. Tailscale was probed too). The host echoes each with0c. The payload has a random id, then032148b9(the host player ID), then client and target addresses (6-byte form as above). The addresses come from the reply’sbdCommonAddr. - DTLS association (Demonware
bdDTLS, not standard DTLS): Init01(16 B) →, ← InitAck02(38 B), CookieEcho03(177 B) →, ← CookieAck04(114 B, often sent twice; the duplicate is ignored). The04carries a DER signature of varying length (3043/3044/3045). All lengths work. - Data
06:06 02 | peer tag (2) | seq (u16 LE) | 8 B (likely MAC), then ciphertext. Payloads are encrypted, so message types inside aren’t visible on the wire. - Full mesh: a joiner must also associate with every other player already in the session, not just the host. If it can’t, it gets “ERROR Unable to connect to all players in the game”.
This probe + DTLS path is the same one online play uses after matchmaking. On LAN there is no introducer server; the probes go straight to the host.
Diagnosing a rejected join
Section titled “Diagnosing a rejected join”Run both peers with BLUR_DWLOG=1 BLUR_NETTRACE=2.
- A healthy join logs “DTLS established” on both sides. Data
06packets follow. - Failures are logged by default (
src/host/dw_log.cpp), withoutBLUR_DWLOG: Demonwarewarn/errlines plus info lines containing “failed”, “timed out” or “Closing”. The per-framegetStatusspam and the always-failing HTTP update check are excluded. An unreachable peer looks like this:[dw] info/bdSocket/nat bdNATTravClient::pump:396 Request timed out. Retrying. (stage 1) (x3)[dw] info/bdSocket/socket bdSocketRouter::onNATAddrDiscoveryFailed:864 NAT Discovery Failed - Local addr:[dw] warn/bdSocket/nat bdNATTravClient::pump:385 Address lookup failed. (Stage 1 failed. No public address so finished.) - If DTLS establishes and the client then sends nothing, the host logs “DTLS receive timeout. Closing.” and the client retries the connect. That points at the game layer above DTLS, not the network. One case seen: a Lua patch wrapping
NetRace.Join*droppedJoinLanServer’s extra return values (start, customrace, event_id). Patches must pass every return value through. - A late NAT-probe reply from another of the host’s addresses after “DTLS established” is harmless.
Session messages inside Data (static):
- Type
0x0a: heartbeat and dropped-player list. Clients send it every 3.0 s; the host sends it immediately when it drops someone (0xb421b0/0xb32060). - Type
0x0b: host-migration announcement.
Race start and traffic
Section titled “Race start and traffic”- On F1 the host sends Data packets of 40 B, then 3 × 1048 B, then 80 B. That looks like a ~3 KB race-setup message in 1048-B fragments (inferred).
- There are no new sockets or ports: the race runs on the same 3074↔3074 association.
| Phase | Host → client | Client → host |
|---|---|---|
| Lobby | ~3 pkt/s, 24–40 B | ~2 pkt/s, 24 B |
| Start + load (~45 s) | 381 pkts, 24–1048 B | 379 pkts, 24–72 B |
| Race | 9.2 pkt/s, 72 B | 9.2 pkt/s, 72–80 B |
The race uses about 5 kbit/s per peer pair in each direction. With 10 players in a full mesh, each peer exchanges with 9 others: ~85 pkt/s, ~50 kbit/s each way.
macOS host layer (src/host/ws2_32.cpp)
Section titled “macOS host layer (src/host/ws2_32.cpp)”- Broadcast rewrite (
send_broadcast, ~l.154): macOS rejects255.255.255.255(EHOSTUNREACH). The host layer instead sends to each up interface’s directed broadcast address. Point-to-point interfaces (Tailscale, most VPNs) have none, so discovery doesn’t cross a VPN, even though joins probe VPN addresses. SO_REUSEADDRalso setsSO_REUSEPORT(~l.313), to match Windows shared-port semantics.GetAdaptersInfo(~l.400): up, non-loopback IPv4 interfaces with MACs.BLUR_OFFLINEfailssocket()with WSAENETDOWN and emptiesGetAdaptersInfo. That disables LAN too.
Env switches
Section titled “Env switches”| Var | Effect |
|---|---|
BLUR_NETTRACE=1 |
log resolves, binds, connects, sendto |
BLUR_NETTRACE=2 |
also receives + hex payloads (up to 2048 B per packet) |
BLUR_DWLOG=1 |
full Demonware log (bdLogMessage). Without it, only warnings/errors/failures are logged |
BLUR_ONLINE_URL=off |
no online redirect. LAN unaffected |
BLUR_OFFLINE |
no networking at all (LAN included) |
BLUR_PORTMAP="3074=3174,50001=50101" |
remap bind ports, for a 2nd instance on one Mac |
BLUR_LANSIM=k |
instance k (1-20) of a simulated LAN on one Mac, see below |
BLUR_ADAPTER_IP=10.77.0.2 |
replace the IP of every reported adapter, for a 2nd instance (otherwise Demonware sees its own address and refuses a “loopback association”) |
Testing two instances on one Mac
Section titled “Testing two instances on one Mac”- Run instance B with
BLUR_PORTMAP+BLUR_ADAPTER_IPas above. BLUR_PORTMAPonly changes binds. Broadcasts still go to port 50001, so any other Blur instance on the machine sees your host, and concurrent LAN tests from different sessions interfere: a bind on 50001 fails, or “No LAN games found”. Run one LAN test per machine at a time.- Discovery replies return to B’s real (mapped) port.
- 3+ instances on one Mac need
BLUR_LANSIM. WithBLUR_PORTMAP, B advertises its unmapped port 3074 and the fakeBLUR_ADAPTER_IP, so a third instance can’t reach B for the full-mesh association (“Unable to connect to all players”).
Testing N instances on one Mac (BLUR_LANSIM)
Section titled “Testing N instances on one Mac (BLUR_LANSIM)”- Start instance k (1-20) with
BLUR_LANSIM=k, each with its own save dir andBLUR_STATE_WSport. Every instance runs the same way; noBLUR_PORTMAP/BLUR_ADAPTER_IP. - The game sees one address, 10.77.0.k. On the wire every instance is 127.0.0.1 with ports 3074, 3075 and 50001 offset by 100·k: sends to 10.77.0.j go to 127.0.0.1 at j’s ports, receives from those ports appear to come from 10.77.0.j at the game port, and a broadcast goes to all 20 instances’ discovery ports.
- Traffic stays on loopback, so LANSIM instances neither see nor disturb real LAN games or other machines.
- Verified 2026-10-19: 5 instances, custom game (Barcelona Oval, 3 laps), all joined, raced and finished (#27).
Open questions
Section titled “Open questions”- Meaning of the title value
0x2c, player-record tail and the +0x58 field. - Message types inside the encrypted Data packets, including race-start (needs a hook on the session layer, not the wire).
- Live host migration with 3+ players (real machines).
- Interop with a Windows/Wine peer (Wine isn’t installed here).