┌──────────────────────────────────┐
│ Relay │
│ │
│ SessionManager │
│ session 42: │
│ host → addr:port │
│ client 2 → addr:port │
│ client 3 → addr:port │
│ │
│ RateLimiter (per source) │
│ PendingConnection queue │
│ PendingReconnect map │
└──────────────┬───────────────────┘
│ UDP
┌───────────────────┼───────────────────┐
│ │ │
┌──────┴──────┐ ┌───────┴──────┐ ┌──────┴──────┐
│ Client │ │ Client │ │ Host │
│ (client 2) │ │ (client 3) │ │ (id=1) │
└─────────────┘ └──────────────┘ └─────────────┘
All UDP traffic flows through the relay. Clients and the host never communicate directly, which means:
- NAT traversal is handled entirely by the relay's single public address
- The host's real address is never exposed to clients
- Clients connect to the relay address, not each other
- Routes packets by destination ID in the header
- Manages the host-registration and connection handshakes
- Enforces per-source rate limits
- Evicts stale connections on a cleanup interval
- Buffers reconnect requests until the host validates them
The relay is stateless with respect to game logic. It parses the packet header plus the payloads of lifecycle packets. HOST_REGISTER, CONNECT_REQUEST, CONNECT_ACCEPT, RECONNECT_REQUEST, and DISCONNECT_NOTICE have relay-specific handlers; all other valid packets are routed opaquely by destination ID.
- Registers a session with the relay
- Assigns client IDs (starts at 2; ID 1 is the host itself)
- Generates a cryptographically random session token per client for reconnect
- Sends
SESSION_CONFIGreliably - Maintains connected/disconnected client state
- Dispatches game packets to the application
- Sends
CONNECT_REQUESTand waits forCONNECT_ACCEPT - Stores session token and client ID for reconnect
- Drives the packet loop
- Sends auto-pings at a configurable interval
- Recreates its UDP socket on reconnect if the original is closed
Host registers
│
▼
[REGISTERED] ─── client joins ──► [ACTIVE: N clients]
│
client disconnects
│
▼
[token retained, N-1 clients]
│
reconnect window expires
│
▼
[slot freed permanently]
The reconnect token window is 5 minutes by default. Within that window a client can rejoin with its original ID. After expiry, the host removes the disconnected-client reconnect state and denies that reconnect attempt; normal client ID allocation continues from a monotonic counter.
1. Client sends CONNECT_REQUEST to relay
2. Relay looks up session, finds host address, adds client to FIFO queue
3. Relay forwards CONNECT_REQUEST to host
4. Host reserves the client name atomically
5. Host allocates a client ID atomically
6. Host sends CONNECT_ACCEPT, SESSION_CONFIG (reliable), PACKET_TYPE_REGISTRY
7. Relay pops FIFO queue → maps clientId→clientAddr in SessionManager
8. Relay forwards CONNECT_ACCEPT to client
9. Client transitions to RUNNING
10. SESSION_CONFIG and PACKET_TYPE_REGISTRY arrive and are processed
The critical invariant: the relay does not update the peer address until the host sends CONNECT_ACCEPT.
1. Client sends RECONNECT_REQUEST to relay (carries token + old clientId)
2. Relay stores pending reconnect keyed by "sessionId:clientId", retaining new address
3. Relay forwards RECONNECT_REQUEST to host using the old address still in SessionManager
4. Host validates token — if invalid, sends CONNECT_DENY
5. CONNECT_DENY is routed normally; the pending reconnect entry is not applied
6. If valid, host sends CONNECT_ACCEPT
7. Relay finds pending reconnect → updates peer address in SessionManager
8. Relay forwards CONNECT_ACCEPT to new address
9. Client receives CONNECT_ACCEPT, stores new token
If the host denies the reconnect, the relay leaves the pending entry in place until cleanup expires it.
DTLS is relay-terminated: each peer maintains a separate DTLS session with the relay. Peers never negotiate DTLS with each other.
Client ──── DTLS ────► Relay ◄──── DTLS ──── Host
When DTLS is enabled, the host and client perform a client-side handshake with the relay before sending any Neon packets. The relay handles inbound handshakes automatically. After the handshake, encryption and decryption are transparent to game code.
Each component runs a processing loop on a dedicated thread or task:
while running:
receive all buffered packets → handle each
check pending ACKs (host)
check cleanup intervals (relay)
check auto-ping (client)
sleep
See PROTOCOL.md for the wire format all implementations must conform to.
For implementation-specific detail — threading model, class structure, DTLS internals — see the architecture doc in the relevant subdirectory.