Connection
How a client proves its address, authenticates the server and gets a session; the server query a launcher sends before connecting; player ids, the player limit and every way a session ends; and the keepalive, acknowledgement, kick and web auth packets.
Handshake
Client Server
| -- ClientHello (235B) --> | (protocol_version + player name, cookie 0, nonce, zeros)
| <-- ConnectChallenge (9B) -- | (cookie; no state on the server)
| -- ClientHello (235B) --> | (same fields + cookie + password proof + credential)
| <-- ServerHello (153B) -- | (version + player_id + session_token + player_token + world baseline
| | + client files port + PreAuth flag + server key + signature)
| -- RequestSpawn (9B) --> | (session_token; repeated on a timer until granted)
| <-- SpawnPlayer (27B) -- | (spawn point + heading + area + skin + variant)Any packet of the exchange may be lost, so the client resends ClientHello every 500 ms and waits 3 seconds for ServerHello; without an answer the connection attempt fails. Instead of ServerHello the server may answer with ConnectReject: no connection was allocated, and the client shows the reason and closes its socket. A refusal without a valid signature over the client's own nonce is dropped as a foreign packet. Every other server packet that arrives before ServerHello is dropped, whatever its length: reliable event lane events sent in the same server tick may overtake ServerHello, and they are resent.
The version is negotiated here only. A ClientHello whose protocol_version differs from the server's PROTOCOL_VERSION gets no answer: an incompatible build times out on connect (fail-closed). The client also checks the version in ServerHello. Packets after the handshake carry no version byte.
Cookie. The return address is proven before anything else. Without a valid cookie the server answers only with ConnectChallenge: it allocates no slot, does not raise player_connecting and remembers nothing. The cookie is a keyed hash of the address, the port and a time window under a random per-process server key; it is never 0 and stays valid for 10–20 s. See ADR 0042.
Server identity. The server's Ed25519 key lives in the file server_identity.key next to resources/ (a 32-byte seed). The server creates it on first start with permissions 600 and never replaces a key already written: servers started at once on one directory end up with one key. A corrupt or unreadable file, a symbolic link in place of the file, and on Unix a file owned by another user or accessible to group or others stop the start.
ClientHello carries nonce, 32 random bytes per connection attempt. ServerHello carries the server's public key and an Ed25519 signature over
"DarqlineMP hello\0" (17 bytes) ‖ nonce ‖ every byte of ServerHello before the signatureThe client takes only a ServerHello with a valid signature over its own nonce (anything else is dropped as a foreign packet) and compares the key with the one the launcher expects for this server; a different key ends the attempt without joining (identity_changed). The signature proves that the key holder answered; it does not protect against a man-in-the-middle on the path, because game traffic is not encrypted.
ConnectReject is signed the same way, over
"DarqlineMP reject\0" (18 bytes) ‖ nonce ‖ every byte of the refusal before the signatureand the client takes only a refusal with a valid signature by the expected key. An IP gets at most 8 signed refusals at once and 2 per second (loopback is not limited); past that a refused ClientHello gets no answer. Addresses beyond the limit table (4096 entries) get no refusal until entries free up.
Credential (ADR 0053). A ClientHello with a cookie may carry the player's server-scoped credential: credential_key is an Ed25519 public key the launcher keeps for this server only, and credential_proof is its signature over
"DarqlineMP credential\0" (22 bytes) ‖ server_key ‖ cookie (u64 LE) ‖ nonceAn all-zero key means no credential, and the proof is not read. A key that is not a valid curve point, or a weak one, fails verification (strict Ed25519 verification). The server checks the credential after the cookie, the password, the name, the connection limits and the attempt limit:
- a proof that does not verify for the server's own key, the cookie and the
noncegetsConnectRejectinvalid_credential; - a
ClientHellowhose credential proof (identified by itsnonce) already opened a connection gets no answer while its cookie lives (up to 20 s); - used proofs are kept until their cookie dies and are never forgotten earlier; while 4096 are held, a new
ClientHellowith a credential also gets no answer.
A repeated ClientHello from an address that already holds a slot neither checks nor changes the credential. Only the auth resource sees the credential: an opaque id, the hex SHA-256 of the key, the same for this computer on this server. Neither the name, the IP nor the credential is an account.
Server password. A server with a password (config password, console password set / password clear, Lua dlmp.set_server_password; empty means no password) admits a new connection only if ClientHello.password_proof is
HMAC-SHA256(K, "DarqlineMP password\0" (20 bytes) ‖ server_key ‖ cookie (u64 LE) ‖ nonce)
K = PBKDF2-HMAC-SHA256(password as UTF-8, salt = "DarqlineMP password\0" ‖ server_key, 600 000 iterations, 32 bytes)- The server derives
Konce per password. The password never crosses the network. - The proof is bound to the address (the cookie), the attempt (
nonce) and the server (its key): it is useless from another address, after the cookie's window, or at another server with the same password. - An observer of the traffic can still guess the password offline against a captured proof (game traffic is not encrypted): each guess costs one derivation of
Kand holds for one server only, so the strength lies in the password length. - The proof protects against replay, not against a live man-in-the-middle: a rogue server the player connected to on their own can forward the player's
ClientHellofrom its own address and get in instead of them. - The password is 1..=64 bytes of UTF-8 without control characters.
- The check comes after the ban and before the name; the proof is compared in constant time. A wrong or missing (all-zero) proof gets
ConnectRejectwrong_password. - Wrong passwords from one IP: 5 at once, then 1 per 30 s. Resends of the same
ClientHello(the same proof) count once; a different proof is a new attempt even with the samenonce. Past the limit the password is not checked and the answer ispassword_attemptswith the wait inban_seconds. Addresses that found no room in the limit table (4096 entries) share one bucket: 5 at once and 1 per second for all of them together. Loopback is not limited, like every other per-IP limit. - Changing the password does not affect connected players; a repeated
ClientHellofrom an address that holds a slot does not check the password.
Example: client_hello.bin carries the proof of the password пароль for the key with seed 0x42×32.
session_token is the connection secret: 64 bits from the OS CSPRNG, never zero, sent only in ServerHello to this address. The client echoes it in every Client → Server packet after the handshake; the server compares it with the token bound to the sender address and silently drops packets that do not match. The server never forwards it to anyone.
player_token is the public generation of a connection: a u32 that grows by one and is unique for the lifetime of the server process. Server → Client packets carry it so that a packet about the previous owner of a reused player_id is discarded; a client learns its own generation from ServerHello. In Client → Server packets it appears only as the name of another player (attacker_token, target_token, the hit target) and authenticates nothing.
Server query
A request for server information before connecting: name, players, revision and proof of the server identity. The launcher uses it to decide whether to trust the server; the public server list (api.darqline.com) polls with the same request. It goes to the game UDP port, outside the handshake and any session. The format is frozen and independent of PROTOCOL_VERSION: answer fields are only ever appended to the end of the body, so a launcher of any version tells an older server from a newer one without connecting.
Request:
[u8 packet_id = 0xFF]
[u8 magic[4] = "DQRY"]
[u8 query_version] // the newest version the asker reads; currently 1
[u8 nonce[32]] // random per request
[u8 zero...] // zeros up to 512 bytes: an answer is never larger than the requestThe server answers only a request of at least 512 bytes with this packet_id and magic; anything else gets no answer. Limits: 4 answers at once and 1 per second per IP, 1000 per second for the whole server; past either limit there is no answer, and an address that does not fit in the per-IP table (4096 entries) gets none either. A request leaves no state.
Answer:
[u8 packet_id = 0xFF]
[u8 magic[4] = "DQRY"]
[u8 query_version] // the server's format version, currently 1
[u8 server_key[32]] // public key of the server identity
[u8 signature[64]] // Ed25519 over "DarqlineMP query\0" (17 bytes) ‖ request nonce ‖ header with the key ‖ body
--- body ---
[u16 protocol_version] // the server's PROTOCOL_VERSION
[str server_version] // server SemVer, up to 32 bytes
[str minimum_client_version] // SemVer, up to 32 bytes; empty means no minimum
[str name] // name from the config, up to 64 bytes
[str mode] // dlmp.set_mode_text, up to 32 bytes; empty until a resource sets it
[u16 players] // connected players, spawned or not
[u16 max_players]
[u8 flags] // bit 0: a password is required (see Handshake); other bits 0str is [u8 n][n bytes of UTF-8]. The "header with the key" is the first 38 bytes (packet_id, magic, query_version, server_key). The answer is at most 273 bytes. An answer that ends before flags means no password. The launcher accepts only an answer with a valid signature over its own nonce and strings without control or format characters, and skips fields after the ones it knows. Byte examples: query_request.bin and query_answer.bin (key seed 0x42×32, nonce 0x5A×32, password required).
Player session
Player IDs and the player limit
player_idsatisfies0 <= id < MAX_PLAYERS(currentlyMAX_PLAYERS = 1000).- On registration the server assigns the lowest free id; a released id is reused.
max_playersslots are open (server config,1..=MAX_PLAYERS, all by default). When they are taken, a new connection getsConnectReject(server_full), and no id reachesmax_playersor above.- At most 5 connections per IP (
too_many_connections), except loopback. - A banned IP (
banned) is refused by the first check, before the name and the limits. The ban list is server state inbans.tomlnext toresources/(address, reason, expiry in unix seconds) and survives a restart; if the server cannot read the file, it does not start. The gamemode sets and lifts bans (dlmp.ban_ip); a ban closes every connection of that IP (KickNotify). - A repeated
ClientHellowith a valid cookie from the same address returns the sameServerHello; the name andlast_seendo not change.
Disconnect
The server removes a player (the id is released and the others get PlayerLeave) in four cases:
- ClientBye. The client sends it best-effort when it shuts down. The slot is released at once.
- Timeout. No packet from the client for longer than
CLIENT_TIMEOUT(15 s). This is the authoritative fallback for a crash, a killed process or a lostClientBye(UDP is unreliable). - Kick. A server-side decision: the server removes the connection and sends the kicked client a best-effort
KickNotify. - Overflow. A guard of reliable delivery: when the client's never-drop critical queue overflows, or when unacked events have waited 15 s with no acknowledgement advancing the queue (the peer stopped sending
ClientAckbut keeps the connection alive with other packets, so the timeout does not catch it), the server drops the dead connection instead of growing the queue without bound. There is no packet for this; the removal takes the same path as a timeout (the others getPlayerLeave).
The client ends the session itself after 15 s without any packet from the server address: the server died, the link is lost, or the server already removed it (a lost KickNotify, a server-side timeout). It still sends ClientBye best-effort. There is no automatic reconnect: after a kick it would bring the player back against the gamemode's will.
Resends of reliable events are limited to 32 per connection per tick (100 ms), oldest first; the rest wait for the next tick.
Every Client → Server packet with a matching session_token refreshes last_seen. The client sends PlayerSync once a fresh local snapshot exists; with unchanged keys a heartbeat goes at least once every 100 ms.
Packets
ClientHello (0x01)
Client → Server, unreliable, resent every 500 ms until answered.
[u8 packet_id = 0x01]
[u16 protocol_version] // PROTOCOL_VERSION; a mismatch gets no answer
[u8 name[64]] // UTF-8 nickname, NUL-padded
[u64 cookie] // 0, or the value ConnectChallenge gave this address
[u8 nonce[32]] // random per connection attempt; signed back in ServerHello / ConnectReject
[u8 password_proof[32]] // server password proof (see Handshake); zeros without a password or with cookie 0
[u8 credential_key[32]] // the player's credential public key for this server (see Handshake); zeros: none
[u8 credential_proof[64]] // Ed25519 by that key over the context, server_key, cookie and nonce; zeros without a keyTotal: 235 bytes (exact). Any other length is rejected without an answer (strict length). A server without a password does not read password_proof. Example: client_hello.bin.
Player name. One rule for ClientHello and for a rename by the gamemode:
- UTF-8 up to the first NUL, with only zeros after it (a character is never cut by the field);
- 3..=32 Unicode scalar values (at most 64 bytes, the field width);
- no control or format (Unicode Cf) characters;
- words are separated by a single ordinary space (U+0020): a leading or trailing space, two spaces in a row and any other whitespace character are not allowed;
- no other player holds it: names compare case-insensitively (Cyrillic included), and
_equals a space.
Otherwise the answer is ConnectReject. The core imposes no roleplay name format ("First Last"); the gamemode checks that in player_connecting.
A repeated ClientHello from an address that already holds a slot does not change the name. It gets a freshly signed ServerHello, at most 8 at once and 2 per second per IP (except loopback); past that it gets no answer.
ConnectChallenge (0x8B)
Server → Client, unreliable.
[u8 packet_id = 0x8B]
[u64 cookie] // the client repeats ClientHello with this valueTotal: 9 bytes. The answer to a ClientHello without a valid cookie; never larger than the request, and it leaves no state on the server. Example: connect_challenge.bin.
ServerKeepalive (0x8C)
Server → Client, unreliable.
[u8 packet_id = 0x8C]
[u32 nonce] // random for every keepalive to every connection
[u16 rtt_ms] // the connection's RTT as the server measures it, ms; 65535: not measured yet, larger values 65534Total: 7 bytes. Sent once per second to every connection, PreSpawn and PreAuth included. Without it a player with no events would hear nothing from the server (see Disconnect). The client answers at once with ClientPong carrying this nonce and shows rtt_ms as its ping (diagnostics screen). The client drops a packet of any other length. A connection in PreAuth always gets rtt_ms 65535. Example: server_keepalive.bin.
Connection RTT. The server keeps one smoothed RTT per connection: rtt = (7 × rtt + sample) / 8, the first sample taken as is. Samples come from the answer to a keepalive (from sending it to the ClientPong with its nonce) and from a ClientAck that acknowledges a new event (from the first send of the newest event it acknowledges), so a player standing still with no events still gets an RTT update every second. The shot and death windows (Health accounting and death) and Player:ping read this RTT. Only the last keepalive can be answered: an RTT above one second is measured only by event acknowledgements.
ConnectReject (0x76)
Server → Client, unreliable.
[u8 packet_id = 0x76]
[u8 reason] // 1 invalid_name, 2 name_taken, 3 server_full, 4 gamemode, 5 too_many_connections, 6 banned,
// 7 wrong_password, 8 password_attempts, 9 auth_unavailable, 10 invalid_credential
[u8 text[20]] // gamemode or ban reason: NUL-padded printable UTF-8; zeros for other reasons
// (its own width, not the name's)
[u32 ban_seconds] // banned: seconds until the ban ends (rounded up), 0 = permanent; password_attempts: seconds
// until the next password check; 0 for other reasons
[u8 server_key[32]] // public key of the server identity
[u8 signature[64]] // Ed25519 over "DarqlineMP reject\0" ‖ nonce from ClientHello ‖ every byte before this fieldTotal: 122 bytes, never larger than ClientHello. The answer to a ClientHello of a new connection with a valid cookie that gets no slot. It leaves no state on the server; the next ClientHello from that address is a new connection. An incompatible version gets no answer at all. Example: connect_reject.bin.
Checks run in this order:
- IP ban (
banned); - server password (
password_attemptswhile the address must wait, elsewrong_password); - name (
invalid_name), name taken (name_taken); - connections per IP (
too_many_connections), free slots (server_full); - the per-IP attempt limit: 5 at once, then 1 per 2 s, except loopback; past it the
ClientHellogets no answer; - credential (
invalid_credential; a used proof or a full used-proof table gets no answer); - the refusable gamemode event
player_connecting(name, ip)(gamemode; a handler error refuses, ADR 0038); auth_unavailable: the server names an auth resource that is not running.
The client drops a ConnectReject with an unknown reason; with a text that holds invalid UTF-8, control or format characters, or nonzero bytes after the NUL; with a non-empty text for any reason other than gamemode and banned; or with a nonzero ban_seconds for any reason other than banned and password_attempts. Such a packet counts as no answer.
ServerHello (0x02)
Server → Client, unreliable (the client resends ClientHello until it arrives).
[u8 packet_id = 0x02]
[u16 protocol_version] // the server's revision (the client checks it too)
[u32 player_id] // the unique id the server assigned
[u64 session_token] // connection secret; the client echoes it in every Client → Server packet
[u32 player_token] // public generation of this connection
[u8 time_hours] // world baseline: CURRENT time of the global clock, hour (0..23)
[u8 time_minutes] // minute (0..59)
[u8 weather] // global forced weather (the game's weather id)
[u16 driveby_interval × 12] // drive-by intervals of weapons 22..=33 (see DriveByIntervals)
[u64 garage_doors] // open garage doors, one bit per id (see GarageDoors)
[u16 client_files_port] // TCP port of the client files on the game server's address, 1..65535; 0 rejects the packet
[u8 auth_pending] // 1: the connection waits for the auth resource (PreAuth); 0: it joined; anything else
// rejects the packet
[u8 server_key[32]] // public key of the server identity
[u8 signature[64]] // Ed25519 over "DarqlineMP hello\0" ‖ nonce from ClientHello ‖ every byte before this fieldTotal: 153 bytes (ADR 0022). Example: server_hello.bin.
ServerHello is session accept plus server-wide configuration. It carries no spawn info and no money: the spawn point, heading, skin and variant come in SpawnPlayer in response to RequestSpawn, and money only in SetMoney. Until SpawnPlayer the player is connected but not materialized (PreSpawn). The walking style is per player and owned by the gamemode (SetWalkingStyle). Later changes of the drive-by intervals and garage doors come in DriveByIntervals and GarageDoors.
PreAuth (ADR 0052). On a server with an auth resource, a new connection waits for that resource's decision after ServerHello: it holds a slot, but for other resources, broadcasts and streaming there is no player.
- The client gets only
ServerHellowithauth_pending1 (also in answer to a repeatedClientHello),ServerKeepalive, and on the reliable event lane the auth resource's login screen if it opened one: the manifest of its client files and its view's events (ViewOpen,ViewData,ViewClose; ADR 0055). - The server accepts from it a repeated
ClientHello,RequestSpawn(never granted),ClientAck,ClientPong,ClientBye,ViewStatusandViewActionof that view, andAuthAssertion; everything else is dropped before any decision. - An accepted connection gets
AuthAcceptedas the next event of the same lane, then the same as a guest right afterServerHello(PlayerSkills, weapon properties, manifests, streaming). - A refusal by the resource, a failure of its handler, a decision timeout (
[auth] timeout_seconds, 120 s by default) and the resource stopping each end the connection withKickNotifycarrying the reason.PlayerLeaveis not broadcast: for the others the player never existed.
time_* and weather are the server-owned world baseline (ADR 0010): the initial presentation of the world, the same for everyone. The client applies it when the world is revealed: it forces the weather and holds the time. The time is frozen by default (noon): the client keeps it at the server's value. time_* carries the current value of the global clock, so with a running clock a newcomer enters at the right time. Runtime changes (per-player SetTime / SetWeather) and the running clock (RunClock) come in their own packets on top of the baseline; the continuous PlayerSync frame does not carry them.
ClientBye (0x05)
Client → Server, unreliable, best-effort when the client shuts down.
[u8 packet_id = 0x05]
[u64 session_token]Total: 9 bytes. The server identifies the client by the sender address; session_token discards a stray or old Bye (for example after the address is reused), so it cannot tear down a live session. Example: client_bye.bin.
PlayerLeave (0x06)
Server → Client, to the other clients when a player is removed (see Disconnect).
[u8 packet_id = 0x06]
[u32 player_id]
[u32 player_token] // generation of the player who leftTotal: 9 bytes. The client retires this player_id only when the generation matches; a stale leave of another generation is ignored. Example: player_leave.bin.
ClientAck (0x0E)
Client → Server, unreliable.
[u8 packet_id = 0x0E]
[u64 session_token] // echoed token; the server compares it (mismatch: drop)
[u32 ack] // cumulative ack: the highest contiguous event_id delivered from the Server → Client laneTotal: 13 bytes. The client acknowledges the server's reliable event lane. The server drops everything with event_id <= ack from its per-recipient queue of unacked events. Like any packet with a valid token, it refreshes last_seen, so a client that only listens stays alive. Example: client_ack.bin.
ClientPong (0x9F)
Client → Server, unreliable, sent as soon as a ServerKeepalive arrives.
[u8 packet_id = 0x9F]
[u64 session_token] // echoed token; the server compares it (mismatch: drop)
[u32 nonce] // nonce of the last ServerKeepaliveTotal: 13 bytes. A sample counts only if nonce matches the last keepalive sent to this connection, and only once: an answer to an earlier keepalive, a foreign nonce and a repeat change nothing. Like any packet with a valid token, it refreshes last_seen. Example: client_pong.bin.
ServerAck (0x0F)
Server → Client, unreliable.
[u8 packet_id = 0x0F]
[u32 ack] // cumulative ack: the highest contiguous event_id delivered from the Client → Server laneTotal: 5 bytes. The server acknowledges the client's reliable event lane. The client drops everything with event_id <= ack from its queue of unacked events. Example: server_ack.bin.
KickNotify (0x14)
Server → Client, unreliable (not on the reliable event lane).
[u8 packet_id = 0x14]
[u8 × 64 reason] // KICK_REASON_LEN, NUL-padded UTF-8, the kick reasonTotal: 65 bytes. A best-effort notice to the kicked client. A kick is a server-side decision (for example the console command kick <id> [reason]; see ADR 0009). The server removes the connection by id, like ClientBye or a timeout (the slot and its state are released), then sends the kicked client one KickNotify; there is nothing to resend it on, since the connection is already gone. The others get a normal PlayerLeave with the kicked player's generation. If the datagram is lost, the client ends the session on server silence (see Disconnect). The client decodes the reason as UTF-8, replacing invalid sequences, ends the session and shows the reason. The server refuses a reason longer than KICK_REASON_LEN instead of truncating it. Example: kick_notify.bin.
AuthAccepted (0xA1)
Server → Client, reliable event lane, critical (never dropped from the queue). The auth resource accepted the connection out of PreAuth (ADR 0053).
[u8 packet_id = 0xA1]
[u32 event_id] // position in the per-recipient Server → Client lane: after the login screen events, if anyTotal: 5 bytes. Sent only after a ServerHello with auth_pending 1, before anything else of the player's join; a guest never gets it. Until it arrives the client treats the login as undecided: a KickNotify or server silence means the login was refused, not that a session ended. Example: auth_accepted.bin.
AuthAssertion (0xA2)
Client → Server, reliable event lane, only from a connection in PreAuth; dropped from a joined player. The identity an auth site signed, as received by the login screen page (ADR 0055).
[u8 packet_id = 0xA2]
[u64 session_token]
[u32 event_id]
[u16 len] // 1..1024
[u8 jwt[1024]] // compact JWT: three base64url parts joined by dots; only zeros after lenTotal: 1039 bytes. The decoder rejects any other alphabet (only A–Z a–z 0–9 - _ .), any number of parts other than three, and a nonzero tail. Example: auth_assertion.bin.
Limits: 3 in a row, then one per 2 s, and at most 10 per connection, valid or not. The server still acknowledges an assertion over a limit (ServerAck), but does nothing with it and reports no outcome.
Only the server verifies the JWT; the auth resource never sees it. Checks, in order:
- header
alg=EdDSA, nocritmember; issis theidof an entry of[[auth.providers]]in the server config, and the signature (over the header and payload parts with the dot between them) verifies with that provider's Ed25519 key;audis the server identity key in lowercase hex (a string, or one element of an array);subis 1..128 bytes without control characters;expis later than now and at most 10 minutes from now;nbf, if present, is not later than now;nonceequals the connection's current ticket (issued by the auth resource throughpending:web_authfor this same provider), compared in constant time.
Any assertion that passes the rate limits spends the connection's ticket: one ticket, one attempt. The outcome goes to the auth resource's callback: (provider, subject), or the reason invalid (malformed, unknown issuer, bad signature, bad sub, exp too far ahead or a missing claim), audience (signed for another server), ticket (no current ticket, another provider's, or a different nonce) or expired (exp passed or nbf not reached).
