Skip to content

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.

  1. Start → login screen (Esc = Skip) → “features unavailable” warning (Enter) → Multiplayer → local area network (Screens/Multiplayer/MpLan.lua).
  2. Entering MpLan starts a search immediately; nothing found → “No LAN games found”.
  3. Browser columns: Host Name / Players / Mode (e.g. blur 1/10 Skirmish Racing). Keys: Enter = Join, Shift = Refresh, Ctrl = Host Game, Esc = Back.
  4. Host Game: MpModeSelect → MpPlaylists → MpLobby. Lobby shows 1/10 players, creator tagged [Host].
  5. Join: client lobby shows 2/10, “Get Ready: Waiting for host”.
  6. Host: F1 = Start Game (Enter = Get Ready). Everyone loads into the race.
  • 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). CreateLanServer accepts 1–20 (static 0xc18b71).
  • 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 0x0b with 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”).
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.

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.EnumerateLanGames 0xc182e0
  • CheckLanServerNetworkVersion 0xc18360
  • CreateLanServer 0xc18b00
  • JoinLanServer 0xc18bf0
  • IsSessionMigrating 0xc15fb0
  • 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 by initKey 0xd09e80) 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 (serialize 0xcdc940, size 0x19):
    • 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.
    • JoinLanServer refuses unless +00 is exactly 0x0f000000. It returns {start, customrace, event_id} from +0x9a/+0x94.
  • Every instance receives every broadcast, its own included, via the directed-broadcast loopback.

All traffic is on UDP 3074 ↔ 3074. Byte 0 is the packet type and byte 1 is the version (2), Demonware framing.

  1. 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 with 0c. The payload has a random id, then 032148b9 (the host player ID), then client and target addresses (6-byte form as above). The addresses come from the reply’s bdCommonAddr.
  2. DTLS association (Demonware bdDTLS, not standard DTLS): Init 01 (16 B) →, ← InitAck 02 (38 B), CookieEcho 03 (177 B) →, ← CookieAck 04 (114 B, often sent twice; the duplicate is ignored). The 04 carries a DER signature of varying length (3043/3044/3045). All lengths work.
  3. 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.
  4. 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.

Run both peers with BLUR_DWLOG=1 BLUR_NETTRACE=2.

  • A healthy join logs “DTLS established” on both sides. Data 06 packets follow.
  • Failures are logged by default (src/host/dw_log.cpp), without BLUR_DWLOG: Demonware warn/err lines plus info lines containing “failed”, “timed out” or “Closing”. The per-frame getStatus spam 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* dropped JoinLanServer’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.
  • 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.

  • Broadcast rewrite (send_broadcast, ~l.154): macOS rejects 255.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_REUSEADDR also sets SO_REUSEPORT (~l.313), to match Windows shared-port semantics.
  • GetAdaptersInfo (~l.400): up, non-loopback IPv4 interfaces with MACs.
  • BLUR_OFFLINE fails socket() with WSAENETDOWN and empties GetAdaptersInfo. That disables LAN too.
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”)
  • Run instance B with BLUR_PORTMAP + BLUR_ADAPTER_IP as above.
  • BLUR_PORTMAP only 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. With BLUR_PORTMAP, B advertises its unmapped port 3074 and the fake BLUR_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 and BLUR_STATE_WS port. Every instance runs the same way; no BLUR_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).
  • 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).