Networking
This document explains the reusable engine networking layers: the secure channel, message buffers, debug/hash handling, client/server connection abstractions, and the ordered UDP transport.
Use it when changing Source/Common/SecureChannel.*, NoiseProtocol.*, NetBuffer.*, NetworkUdp.*, client/server connections, or network tests.
Ownership model
The engine owns the secure channel, transport abstractions, message framing, ordered UDP behavior, and client/server connection interfaces. An embedding project owns deployment topology, pinned production keys, rotation and operational policy, and game-specific command usage.
Do not document project-specific hosts, ports, or release infrastructure here.
Source paths inspected
Source/Common/NetBuffer.hSource/Common/NetBuffer.cppSource/Common/NoiseProtocol.hSource/Common/NoiseProtocol.cppSource/Common/SecureChannel.hSource/Common/SecureChannel.cppSource/Common/NetworkUdp.hSource/Common/NetworkUdp.cppSource/Common/Settings.incSource/Client/NetworkClient.hSource/Client/NetworkClient-Interthread.cppSource/Client/NetworkClient-Sockets.cppSource/Client/NetworkClient-UdpSockets.cppSource/Server/NetworkServer.hSource/Server/NetworkServer-Interthread.cppSource/Server/NetworkServer-UdpSockets.cppSource/Server/NetworkServer-Asio.cppSource/Server/NetworkServer-WebSockets.cppSource/Server/Server.cppSource/Server/ServerConnection.hSource/Server/ServerConnection.cppSource/Tests/Test_NetworkUdp.cppSource/Tests/Test_NetworkClient.cppSource/Tests/Test_NetworkServer.cppSource/Tests/Test_ClientServerIntegration.cppSource/Tests/Test_Cryptography.cppSource/Tests/Test_NoiseProtocol.cppSource/Tests/Test_SecureChannel.cpp
Secure channel
Every connection, including an in-process interthread connection, carries an ordered stream of sealed frames before any NetMessage::Handshake or updater data. The engine implements Noise_NK_25519_ChaChaPoly_BLAKE2b (Noise revision 34): the client has no pre-login identity, while the server proves possession of a static X25519 secret whose public half the client pins. The one-round-trip handshake derives fresh per-direction session keys. Network observers and forged servers cannot read or inject gameplay or updater messages; a party controlling the client process can still extract its session keys and send through a genuine session, so server-side authority and inbound validation remain necessary.
The same SecureChannel runs over TCP, ordered UDP, WebSocket and interthread transports. WebSocket pinning is independent of the Web PKI used by WSS. The server-to-client stream is compressed before sealing; the client-to-server stream, which carries logins and tokens, is not compressed. Every frame has a big-endian 16-bit length and one Noise message. Transport payloads contain at most 65,519 plaintext bytes plus a 16-byte authentication tag. A frame with an invalid length is rejected before its body is buffered. The ordered UDP sequence/acknowledgement header remains outside the channel and cannot inject authenticated payloads. Connection shutdown, UDP-to-TCP fallback and reconnect start fresh channels; there is no plaintext fallback.
There is one bounded exception to the no-plaintext-session rule: the server recognizes the frozen first-message signature of a client predating the secure channel and sends only a fixed legacy handshake answer with updater_outdated (compressed when configured). That client then closes; no gameplay or updater exchange follows in plaintext. Other malformed input still takes the secure-channel rejection path. This refusal is marked FO_TEMPORARY_COMPAT(PreChannelClient, "2026-12-31") at its implementation, state, and tests, and follows the temporary-compatibility removal gate. Test_SecureChannel.cpp pins the reply through hand-driven and real TCP/UDP connections.
| Frame | Sender | Body |
|---|---|---|
| Offer | Client | Count from 1 to SecureChannel::MAX_OFFERED_KEYS (4), then a first NK message for each pinned server key |
| Answer | Server | Selected offer index and the second NK message; a server that opens none disconnects |
| Transport | Either side | One authenticated Noise transport message |
Both handshake messages have empty payloads and use the fixed SecureChannel::PROLOGUE. Each offered pin has its own ephemeral key. Offering the old and next pins together permits a staged key rotation without a second handshake. Nonces advance per direction; replayed, reordered, dropped and reflected frames fail authentication. A channel error is a NoiseException (or derived SecureChannelException) and closes the connection. The server latches receive-path rejection for its owning worker to disconnect with ProtocolError, logging a warning rather than treating an unauthenticated stranger as an engine exception.
| Setting | Owner | Contract |
|---|---|---|
ServerNetwork.ChannelSecretKey |
Server | Required static X25519 secret, exactly 64 hex digits, even if external networking is disabled; a missing or malformed value prevents startup |
ClientNetwork.ChannelServerKeys |
Client | One to four pinned public keys, each exactly 64 hex digits; an empty or invalid list prevents connection |
BuildTools/secure_channel_key.py generate <secret-file> creates a new owner-readable secret file without overwriting one and prints its public key; public <secret-file> derives the public half of an existing key. Never bake a production secret into a client or repository config. Use target-host provisioning such as $TARGET_FILE{...} for the server value, publish only the public pin to clients, and follow Security and Secrets. For rotation, ship both pins first, switch the server’s secret, then remove the old pin after old clients are retired. The engine test fixture has its own deterministic key pair; that fixture is not a production identity.
Message buffers
Source/Common/NetBuffer.h defines the shared binary message layer:
NetBuffer— common storage, growth, and raw copy support; confidentiality and integrity belong toSecureChanneloutside the message buffer.NetOutBuffer— write/framing helper for outgoing messages.NetInBuffer— read/framing helper for incoming messages.
Important constant: NETMSG_SIGNATURE = 0x011E9422, the marker at the start of each framed message inside the channel.
NetOutBuffer responsibilities:
- append raw bytes via
Push(); - write arithmetic/enums/plain property types through typed
Write(); - write strings and hashed strings;
- write property data blocks with
WritePropsData(); - frame messages with
StartMsg()andEndMsg().
NetInBuffer responsibilities:
- accumulate incoming bytes with
AddData(); - determine when a full message needs processing with
NeedProcess(); - read typed values, strings, and hashed strings;
- read property data with
ReadPropsData(); - parse message IDs with
ReadMsg(); - expose
GetUnreadSize()for only the current framed message andGetBufferedUnreadSize()for all retained input; - require exact consumption of the current frame before
ShrinkReadBuf()or the nextNeedProcess()advances past it; - shrink/reset read buffers after processing.
Property synchronization and entity state transfer should go through these helpers instead of hand-rolled byte layouts.
Inbound hardening (untrusted client → server)
The server treats all inbound bytes as hostile. Several complementary layers guard against resource-exhaustion and malformed input:
- Length-before-allocation rule. Any peer-declared length/count must be validated against the bytes actually remaining in the current frame before allocation or iteration.
NetInBuffer::Read<string>()andNetInBuffer::ReadPropsData()reject (NetBufferException) when the declared length exceedsGetUnreadSize(), so a tiny message cannot borrow bytes from the next coalesced frame or amplify into a multi-GB allocation. Inbound remote-call decoding uses a read-onlyDataReader: string bytes are bounds-checked as a borrowed view before constructing the owned string, while array, dict, and dict-of-array counts are charged against the remaining payload using each value type’s minimum wire size beforeReserve(), container creation, or a count-driven loop. The server-side content validator performs the same count preflight. - Maximum message size.
NetInBuffer::SetMaxMsgLen(len)sets an upper bound on a single framed message;NeedProcess()throwsUnknownMessageException(→ hard disconnect) at the header whenmsg_lenexceeds it, before the receive buffer accumulates the payload. The server sets this fromServerNetwork.MaxMessageSize(0 = unlimited); the client leaves it unset so large server→client sync still works. All server-inbound messages are small control messages, so the default cap is well above any legitimate value. - Maximum retained input.
NetInBuffer::SetMaxBufLen(len)caps total unread bytes retained across coalesced or partial frames, so a peer cannot grow the receive buffer by never completing a message. The server appliesServerNetwork.MaxBufferedInputSize(default 4 MiB) to TCP, UDP, interthread, and WebSocket input; the value must be0(unlimited) or at leastMaxMessageSize, otherwise server startup fails.AddData()reports overflow on the network thread, whileServerConnectionlatches it once and the owning worker hard-disconnects the peer without recursively taking the transport lock. - Remote-call structural limits. A
///@ RemoteCallmay declareMaxBytes NandMaxCollectionSize N. Metadata carries both limits for every call: a declaration without either option is baked with the explicitLimits 0 0trailer, and registration rejects records without it. The server resolves the call name before allocating its body; the native validator and each enabled scripting decoder enforce the collection limit before reserve or container creation. AngelScript covers arrays, dictionaries, and nested dictionary arrays; the current Managed C# bridge uses the same wire reader but supports scalar and array arguments only. Unknown calls are rejected before body allocation. Adding the mandatory trailer changed compatibility metadata and therefore requires the corresponding compatibility-version bump. See Remote Calls for the complete backend matrix. - Remote-call runtime ceiling.
ServerNetwork.MaxRemoteCallPayloadSize(default 1 MiB) is the server-wide decoded-RPC ceiling. The effective payload ceiling is the smaller nonzero value of this setting and per-callMaxBytes; the structural call limit is the semantic protocol boundary, not a replacement for the global hostile-input limit. - Per-pass message budget. The server drains at most
ServerNetwork.MaxMessagesPerProcessPassmessages per connection per worker-job pass, then yields; the periodic player job reschedules, so leftover buffered messages drain on the next pass and one flooding connection cannot monopolize a worker thread shared with world jobs. - UDP reorder window.
UdpTransportOptions.MaxReorderAhead(server:ServerNetwork.MaxUdpReorderAhead) bounds how far ahead of the next expected sequence the out-of-order reassembly map (_receivedPackets) buffers; payloads beyond the window are dropped (the sender retransmits), so a peer that never sends the in-order packet cannot grow the map without limit. - Pre-handshake parse failures stay operational noise. A malformed payload that raises
NetBufferExceptionbeforeServerConnection::IsHandshakeComplete()is logged once as an invalid-handshake warning with the remote host/port and hard-disconnected without invoking the global exception reporter. The same exception after a completed handshake still follows normal exception reporting.ServerRejectsMalformedPreHandshakePayloadWithoutExceptionReportinSource/Tests/Test_ClientServerIntegration.cpppins the distinction.
The per-type content validator (ClientDataValidation.*, invoked for client property writes and inbound remote-call payloads) is the complementary layer: it enforces finite floats, valid UTF-8, rejection of embedded NUL bytes in strings (a NUL is valid UTF-8 but never legitimate client text and is dangerous for C-string/log/DB consumers), non-negative sizes, count-to-payload consistency, enum/hash/proto resolution, and the declared remote-call collection ceiling. Absolute input and flood ceilings still live in the buffer/transport layer above.
Hashes
Network buffers can serialize hstring values: NetOutBuffer writes the 64-bit hash, and NetInBuffer resolves it back to a string through a HashResolver.
When changing hash serialization, inspect both generated metadata/hash registration and runtime network consumers.
Unresolved hash recovery
Client and server build their hash storages independently from local resources, so the server can transmit an hstring that was created at runtime (or that lives in content the client lacks) and which the client cannot resolve. NetInBuffer::ReadHashedString resolves the raw hash through the supplied HashResolver; when that lookup fails, the resolver’s failure handler sees the raw hstring::hash_t, the input buffer is reset, and ReadHashedString throws a regular NetBufferException. The same handler also covers non-buffer lazy resolves, such as converting raw replicated property data into AngelScript hstring, arrays, dictionaries, or proto-reference objects.
What fills that client storage decides which strings are at risk. Startup registers local metadata,
proto/fixed-type identities and hashed property values (including authored Server values), client-compiled
script literals, text keys, sprite paths and model names. ProtoBaker carries server-pack strings in the
client prototype dictionary without enabling server property access. A map’s fomap-bin-client table
arrives only when that map loads (MapView::LoadStaticData); it also carries the server map’s strings,
including critter/dynamic-item overrides, without serializing those server entities as client records.
Strings in these public dictionaries are not secrets merely because their source property is Server.
A server-only script literal, a runtime-composed value, or a map value delivered before its map loads still has no automatic client registration. A producer must establish the receiving pool before syncing the hash, for both AngelScript and Managed C#. The recovery below diagnoses a missing string after the failed read; it is not the normal delivery channel. See Baking for the coupled output and incremental collection rules.
The engine recovers from this instead of looping on the disconnect:
ClientEngineregisters aHashStorageresolve-failure handler. When the client hits an unknown hash on an established connection, the handler writesNetMessage::UnresolvedHashand performs one immediate pending-output flush.ClientConnection::Processstill turns the followingNetBufferExceptioninto a normal disconnect for direct buffer reads; lazy script/property exceptions may be contained by the script event system, so the server also hard-disconnects the reporter after receiving the hash. The report is tiny and the connection was just live, so it lands in the kernel send buffer without a sleep/retry busy-wait. If a wedged socket drops it, the client re-reports the same hash the next time it hits it, so no bounded-wait loop is needed. The client keeps no state, writes nothing to disk, and learns the string on the next normal reconnect.- The server (
Process_UnresolvedHash) resolves the reported hash against its own storage, logs it, and — when it can resolve the string — stores it in the persistentHashReportsdatabase collection (keyed by the string) and remembers it in memory. Hashes the server cannot resolve either are logged once per session and not stored. If a transport reports the close before the server worker reaches already-delivered input, the server checks a hard-disconnected connection for a pendingUnresolvedHashbefore cleanup. The server then drops the connection (HardDisconnect), since a client that reported a bad hash has already stopped parsing the stream and is reconnecting — this also covers a client that reports without disconnecting itself. - The server broadcasts a newly learned string to all already-connected clients (
NetMessage::HashList) and, on every handshake, sends the full known set to the connecting client right afterInitData(SendAllReportedHashes).HashListis a count followed by length-prefixed strings. - Clients feed each received string through
HashResolver::ToHashedString, which registers the same hash locally, so subsequent resolves of that hash succeed. Because the server resends the full set on every connect, a client that reported a hash and dropped resolves it after reconnecting.
The reported strings are stored raw (not registered into the server hash storage) so the server can keep and rebroadcast them without recreating dead entries. On startup the server loads the persisted HashReports collection after static content is loaded but before runtime/world strings are created, and checks each stored string with HashStorage::CheckHashedString (a non-inserting existence check). A reported gap is treated as fixed once its string resolves — i.e. the missing data was added to content — so it is deleted from storage and no longer broadcast. A string that is still unresolvable is logged with a warning, kept, and rebroadcast, since the underlying content is still missing.
This is a serialized contract change: NetMessage::HashList (server→client) and NetMessage::UnresolvedHash (client→server) were added, so the central compatibility marker in Source/Common/Common.h is bumped accordingly.
Client connection abstraction
Source/Client/NetworkClient.h defines NetworkClientConnection.
Compressed client/server traffic is one continuous zlib stream flushed with Z_SYNC_FLUSH; transport reads may split or coalesce its bytes and are not independent compressed packets. Malformed input cannot be skipped or resynchronized inside the same connection. StreamDecompressor reports peer-stream failures as DecompressException, and ClientConnection treats that as a protocol failure: it logs the error, disconnects, and resets its buffers and decompressor so a later reconnect starts from a clean stream. It does not retry the same bytes, continue on the poisoned stream, or reinterpret decompression failure as a UDP-to-TCP fallback condition.
The public surface is transport-neutral:
IsConnecting()/IsConnected();- byte counters:
GetBytesSend(),GetBytesReceived(); CheckStatus(for_write);SendData();ReceiveData();Disconnect().
Factory methods choose transport implementation:
CreateInterthreadConnection()CreateSocketsConnection()CreateUdpSocketsConnection()
Concrete files include:
NetworkClient-Interthread.cppNetworkClient-Sockets.cppNetworkClient-UdpSockets.cpp
The client runtime should depend on the abstract connection interface where possible; transport-specific behavior belongs in the implementation files.
Server connection abstraction
Source/Server/NetworkServer.h defines two server-side abstractions:
NetworkServerConnection— one accepted/active connection.NetworkServer— listening server lifecycle.
NetworkServerConnection owns callback registration and dispatch:
SetAsyncCallbacks(send, receive, disconnect);Dispatch();Disconnect();GetHost()/GetPort();IsDisconnected().
The send callback returns outgoing bytes by value, and each transport owns the buffer handed to its socket. A borrowed sender buffer can be refilled by another dispatch or freed during disconnect while an I/O thread is still compressing or sending it. Disconnect() clears the callback under the same lock used for invocation, so teardown waits for any in-flight pull and later transport ticks cannot reach a disappearing sender.
NetworkServer keeps weak references to every accepted connection. Shutdown() first closes registration
against concurrent accepts, snapshots and disconnects all still-live connections, and only then invokes the
transport-specific listener/io-context shutdown and thread join. A connection accepted concurrently with
shutdown is either included in that snapshot or rejected and disconnected by TrackConnection(); it cannot
escape between the accept callback and io-thread teardown. Repeated Shutdown() calls are no-ops.
The server runtime applies two independent limits to connections that have not logged in:
ServerNetwork.InactivityDisconnectTimelimits silence between any inbound messages;ServerNetwork.LoginTimeoutlimits time without meaningful pre-login progress (0 disables it). Handshake, authentication remote calls, and update-file requests refresh progress; transport pings do not. This lets a legitimate updater continue while preventing a peer from keeping an unauthenticated slot forever by only answering pings.
A logged-in connection is also dropped when it stops answering pings. ServerNetwork.ClientPingTime sets the interval; if the previous ping remains unanswered when the next one is due, the server records PingTimeout and hard-disconnects the connection. The in-process interthread transport opts out of this watchdog because its peer lifetime is explicit through the callback channel and a busy shared process can delay both ends together; closing either interthread endpoint still disconnects its peer immediately. It is still pinged for latency measurement, but a late answer merely postpones the next ping. NeedPing() schedules both transports; NeedsPingWatchdog() selects the disconnect policy.
The server pairs RegisterPingRequest with RegisterPingAnswer and smooths the measured round trip in GetRoundTrip(). Unpaired answers are ignored. Because the client answers from its frame loop, this is an upper bound including client scheduling, not pure wire latency. It is zero before the first answer and is never chosen by the client.
The client reports a movement it finished predicting
The client starts predicting its own movement before its SendCritterMove reaches the server. An interrupted plan already sends SendStopCritterMove with its final position. When a plan plays to its end, the chosen critter instead sends SendCritterMoveFinished with map and critter ids, the plan’s end hex, and final position and direction. Process_MoveFinished checks that the report names the current plan and reconciles through the same path and blocking checks as a stop. The end hex identifies the plan because the initiating client does not receive the server’s CritterMove id.
The maximum fast-forward is min(round trip / 2, Server.MoveFinishCatchUpMaxMs) + Server.CritterMovingPeriodMs; without a measured round trip only the movement period applies. An early, stale, invalid, or superseded report is rejected. The connection’s message order ensures arrival reconciliation completes before an action request behind it is processed. Ordinary arrival sends no redundant position broadcast; a correction is broadcast only if the reconciled hex differs from the plan’s end hex. See Server Runtime.
Movement synchronization trace
Network.MoveSyncTrace (off by default) logs movement synchronization on server and clients. Each event is one stable, machine-readable line:
MOVESYNC side=<srv|cl> ev=<event> t=<monotonic µs> st=<synchronized ms> [viewer=<chosen id>] key=value…
The local monotonic t aligns processes on one machine. Across machines, st is shared but client time sync delivery makes latency estimates approximate. Client lines identify their chosen critter with viewer. Server events include move_req, move_start, step, stop, stopmove_req, finish_req, speed_change, and send; client events include move_send, stop_send, finish_send, move_recv, pos_recv, teleport_recv, speed_recv, step, arrive, in, and out. finish_req records its outcome (accepted, not_moving, attached, stale_plan, too_early, invalidated, superseded, or reconcile_failed) and the reported position, remaining/allowed time, and measured round trip. Embedding projects may add scenario mark and scripted input events; the engine does not own their runners or interpretation. The trace is too verbose for production.
Disconnect reasons
Every close records its cause in DisconnectReason, and HardDisconnect(reason) requires callers to choose one:
| Reason | Cause |
|---|---|
None |
still connected |
ClientClosed |
the transport reported that the peer disappeared; voluntary quit and lost network are indistinguishable here |
InactivityTimeout |
no inbound message before InactivityDisconnectTime |
PingTimeout |
the previous ping was not answered |
LoginTimeout |
no pre-login progress before LoginTimeout |
ProtocolError |
malformed/unexpected data or failed connection publication |
UpdaterError |
invalid update-file request |
ServerShutdown |
orderly server stop |
ScriptRequest |
Player.HardDisconnect() from script |
LoginFailed |
login rolled back after a server-side failure |
ReplacedByReconnect |
a new login for the same account replaced this session |
The first recorded reason wins, preventing the transport’s later generic ClientClosed callback from overwriting the specific cause. The reason appears in the closed-connection log and is available to OnPlayerLogout handlers through Player.GetDisconnectReason(). ServerConnectionRecordsWhyItWasDisconnected pins this contract.
ServerDisconnectsPreLoginConnectionAfterLoginTimeout covers the runtime deadline, while
NetworkServerInterthreadCopiedListenerRejectsAfterShutdown, NetworkServerInterthreadOptsOutOfPingWatchdog, and the transport shutdown tests cover accepted connection ownership, concurrent accept rejection, and the interthread watchdog exemption.
NetworkServer starts transport-specific servers through factories:
On the client side, ClientConnection also bounds silence. During secure-channel handshaking, or while a ping is outstanding, receiving no bytes for ClientNetwork.PingTimeout milliseconds (default 30000; zero disables the timeout) takes the ordinary disconnect path. Any incoming bytes count as progress, avoiding a false timeout during a large update portion; debugger sessions suppress this watchdog. A reconnect clears the pending ping. Source/Tests/Test_ClientUpdater.cpp covers a silent updater server.
Server-side connection owners must call DropAsyncCallbacks() before destruction. It clears send, receive, and disconnect callbacks under their locks and waits for a callback already in progress, including a disconnect notification racing transport teardown (Source/Server/ServerConnection.cpp).
StartInterthreadServer();StartUdpSocketsServer();StartAsioServer()whenFO_HAVE_ASIOis enabled;StartWebSocketsServer()whenFO_HAVE_WEB_SOCKETSis enabled;CreateDummyConnection()for tests/special paths.
The listen ports and the client connect endpoint are configured per transport:
- TCP listens on
Network.ServerPort; UDP onNetwork.ServerPort + Network.UdpPortOffset. - WebSocket(S) listens on
Network.WebSocketPort. - The client connects plain TCP/UDP to
ClientNetwork.ServerHost:Network.ServerPort, and WebSocket(S) toClientNetwork.WebSocketHost:Network.WebSocketPort— so the WebSocket endpoint can keep a hostname (for its TLS certificate) while the TCP/UDP endpoint can be a raw IP, letting a native client connect without DNS resolution.
Each endpoint is configured explicitly: the WebSocket host and port are independent settings, not
derived from ServerHost / ServerPort.
Socket diagnostics must not use std::error_code::message(), std::system_error::what(), strerror(),
or FormatMessage() directly because those APIs return text in the host OS locale. Route socket failures
through net_sockets::error_text() instead. It maps common network conditions to stable English names and
always retains the native category and numeric code; unknown conditions use the English fallback
Network error (<category>:<code>). Listener startup exceptions must also add the transport and port so
an occupied TCP or WebSocket endpoint is actionable without relying on localized system text.
Concrete files include:
NetworkServer-Interthread.cppNetworkServer-UdpSockets.cppNetworkServer-Asio.cppNetworkServer-WebSockets.cpp
Async transport connection lifetime & threading
The socket-based server connections (Asio, WebSockets) run their io loop on a dedicated thread while
the engine worker pool drives Dispatch()/Disconnect() on the connection from other threads, so the
connection wrapper’s lifetime must be disciplined:
- The wrapper must never be dereferenced by an io-thread callback after the engine drops it. The Asio
transport gets this for free by capturing
shared_from_this()in every async read/write handler. The WebSockets transport wires its persistent websocketpp handlers post-construction (aStart()method, becauseweak_from_this()is unusable in the constructor) and each handler locks aweak_from_this()before touching the wrapper — a rawthiscapture is a use-after-free. - Cross-thread teardown must use the transport’s thread-safe path. For WebSockets that is
connection->close()(it posts to the io service), never the io-thread-onlyconnection->terminate(). - The wrapper must not extend the underlying connection’s lifetime past its owning io_context. The websocketpp endpoint owns each connection (with its io_context-bound asio timers) and destroys it on the io thread; the wrapper therefore holds the connection weak and locks per use. A strong ref lets a surviving wrapper destroy the connection after the io_context is gone — a shutdown-time use-after-free.
Test_NetworkServer.cpp covers each transport end-to-end (interthread, Asio accept-rearm and shutdown with an
accepted TCP connection, and a real websocketpp client that sends a frame then relies on server shutdown to
disconnect it); run it under the AddressSanitizer job to guard these lifetime rules.
Ordered UDP transport
Source/Common/NetworkUdp.h implements an ordered/reliable payload layer over UDP.
Packet types:
ConnectAcceptPayloadKeepAliveDisconnect
UdpTransportOptions controls:
MaxPayloadMaxPendingBytesMaxReorderAheadResendTimeoutMsConnectRetryMsRedundancy
UdpPacketInfo carries parsed packet data:
- packet type;
- session ID;
- sequence number;
- acknowledgement sequence;
- acknowledgement bitmask;
- extra value;
- payload bytes.
UdpOrderedChannel owns session state and reliable ordering:
- session state:
GetSessionId(),HasSession(),SetSessionId(),Reset(); - output readiness:
NeedSend(),CanAcceptPayload(); - packet creation/resend:
PrepareOutput(); - incoming sequence handling:
HandleIncomingPayload(); - ordered delivery:
HasReadyData(),ExtractReadyData(); - disconnect:
MakeDisconnectPacket().
Standalone helpers:
MakeUdpConnectPacket()MakeUdpAcceptPacket()TryParseUdpPacket()
When changing UDP behavior, validate acknowledgement handling, pending-byte limits, resend timing, packet parsing, disconnect handling, and redundant tail packets.
Relationship to entity and property state
Entity/property synchronization uses property metadata to decide what can be sent and network buffers to serialize the data.
Relevant property flags from Entity Model:
SyncedOwnerSyncPublicSyncNoSyncModifiableByClientModifiableByAnyClient
A network change that affects property replication should be reviewed together with entity/property docs and tests.
Transport selection
The source tree supports several connection families:
- interthread transports for in-process/test-style communication;
- socket transports;
- UDP socket transports backed by
UdpOrderedChannel; - ASIO server support when built with
FO_HAVE_ASIO; - WebSocket server support when built with
FO_HAVE_WEB_SOCKETS.
Build availability is controlled by compile-time feature toggles and platform dependencies. For build toggles and package workflow, see Build Workflow and BuildTools Pipeline.
A listener that cannot bind is retried before the startup gives up
A restart races the process it replaces for its ports, and the loser used to take the whole startup
down on its first attempt: the world loaded, a socket that frees itself within seconds was still
held, and every bit of that work was thrown away. Each remote listener is therefore started through
ServerEngine::StartConnectionServer, which retries until ServerNetwork.ListenRetryTime runs out,
waiting ServerNetwork.ListenRetryDelay between attempts.
Past the deadline the original exception is rethrown and the startup fails, because a server nobody can reach is not a started server — the retries buy the losing side of the race some time, they do not turn a dead port into an acceptable state. The interthread transport is not part of this: it binds nothing another process could hold, so a failure there is a defect rather than a race.
Tests to inspect
Relevant tests include:
Source/Tests/Test_NetworkUdp.cppSource/Tests/Test_NetworkClient.cppSource/Tests/Test_NetworkServer.cppSource/Tests/Test_ClientServerIntegration.cppfor the in-process client/server handshake and connection-event path.
Change routing
- Binary framing/encryption/hash serialization:
Source/Common/NetBuffer.*. - Ordered UDP behavior:
Source/Common/NetworkUdp.*. - Client transport abstraction and implementations:
Source/Client/NetworkClient*. - Server transport abstraction and implementations:
Source/Server/NetworkServer*. - Entity/property replication semantics: Entity Model and generated metadata/property code.
- Build feature toggles: Build Workflow and BuildTools Pipeline.
Validation checklist
- Run UDP, client, and server network tests relevant to the changed transport.
- Validate both connect/accept and disconnect paths.
- Validate partial receives and message framing when changing
NetInBuffer/NetOutBuffer. - Validate hash resolution/debug-hash behavior across client and server builds.
- Validate property synchronization when message layout or property-data serialization changes.
- Validate platform-specific transport availability when touching ASIO/WebSocket/socket code.