View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

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/NetBuffer.*, NetworkUdp.*, NoiseProtocol.*, SecureChannel.*, Source/Essentials/Cryptography.*, Source/Client/NetworkClient*, Source/Client/ClientConnection.*, Source/Server/NetworkServer*, Source/Server/ServerConnection.*, or network tests.

Ownership model

The engine owns transport abstractions, message framing, ordered UDP behavior, and client/server connection interfaces. An embedding project owns deployment topology, public server addresses, operational policy, and game-specific command usage.

Do not document project-specific hosts, ports, or release infrastructure here.

Source paths inspected

Secure channel

Every connection that crosses a network carries its bytes inside a Noise channel: the protocol Noise_NK_25519_ChaChaPoly_BLAKE2b (Noise Protocol Framework, revision 34). NK fits the game’s shape: a client has no identity of its own before it logs in, while the server’s static key is known in advance and pinned in the client. One round trip therefore authenticates the server, derives fresh session keys (so a later theft of the server key does not open recorded sessions), and from then on every byte is encrypted and authenticated in each direction, with per-direction nonce counters that reject a replayed, reordered, dropped or reflected frame.

What it protects against: an observer or a man in the middle on the path (public Wi-Fi, a provider, forged DNS), and a forged server — a client never speaks to a server that cannot prove it holds a pinned key, so such a server can neither read the login nor call the client’s remote calls. What it cannot protect against: the owner of the client machine. Whoever controls the process can patch it, read the session keys from memory and send forged messages through a genuine session; server authority and the inbound hardening below remain the defence there.

Where it sits

The channel wraps the ordered byte stream every transport already provides (TCP, ordered UDP, WebSocket and the in-process interthread one), so the engine has one path for all of them, and WebSocket clients are pinned to the server key independently of the Web PKI that WSS uses underneath. The channel handshake runs before NetMessage::Handshake, so nothing travels in the clear — updater traffic included. Order in the stack:

Sealing traffic that never leaves the process protects nothing, since whoever can read it can read the session keys too. The interthread transport runs the channel anyway, so every embedded client — in tests, tools and development launches — takes the path a remote player does. There is no exception at all: every ServerConnection owns a channel, and a stand-in with no peer behind it (NetworkServer::CreateDummyConnection()) simply never receives an offer, so whatever is written to it stays sealed away in its output buffer.

Wire format

Every frame is a big-endian 16-bit length followed by that many bytes. The prologue of every handshake is the fixed string SecureChannel::PROLOGUE, and both handshake messages carry empty payloads.

Frame Sender Body
Offer client, first a count n (1 to SecureChannel::MAX_OFFERED_KEYS), then n first NK messages (e, es: 32-byte key + 16-byte tag), one per pinned server key
Answer server the index of the offer its key opened, then the second NK message (e, ee: 48 bytes)
Transport both, afterwards one Noise transport message: at most 65519 bytes of plaintext and its 16-byte tag

The client offers every pinned key at once, each with its own ephemeral key, and the server answers the one it can open. A server that opens none sends nothing and drops the connection. Frame sizes are checked at the header, before the body is buffered: a handshake frame of a size no valid offer or answer has, or a transport frame shorter than a tag, fails the channel at once.

Keys

Setting Side Meaning
ServerNetwork.ChannelSecretKey server the static X25519 secret key, 64 hex digits; every server needs it, one that listens on nothing included, since every connection it creates owns a channel; the server logs its public half at networking start
ClientNetwork.ChannelServerKeys client the pinned server public keys, 64 hex digits each, at most SecureChannel::MAX_OFFERED_KEYS

A server refuses to start without a valid key, and a client without a valid pin fails the connect: there is no unprotected fallback. Engine tests share BakerTests::TEST_CHANNEL_SECRET_KEY and its public half, which ApplySelfContainedServerSettings() and ApplySelfContainedClientSettings() apply; a ServerConnection a test builds by hand takes BakerTests::MakeTestChannelIdentity(). The secret key is a credential — keep it out of the repository and supply it at run time, for example through $TARGET_FILE{...} (see ConfigurationAndDataSources.md), which reads it on the target host and never at bake time. BuildTools/secure_channel_key.py generate <file> writes a new secret key readable by its owner only (it never overwrites an existing file) and prints the public key; public <file> prints the public key of an existing one.

Rotation needs no downtime because the client offers all its pins: ship clients that pin both the current and the next key, switch the server to the next key once those clients are out, and drop the old pin later.

Connection integration

ClientConnection starts the channel when the transport connects and writes NetMessage::Handshake the moment the channel is established; its ping waits for the same point, so the server never reads anything before the handshake message. A UDP connect that falls back to TCP starts a new channel and a new compressed stream on the new connection. Any channel failure is a NoiseException (SecureChannelException derives from it) and ends in Disconnect().

ServerConnection keeps the channel under its own mutex, taken inside either buffer lock and never around another: the network thread decrypts under the input lock, the transport’s send path seals under the output lock. The answer to an offer is dispatched from the receive path at once. A message written before the channel stands stays in the output buffer and leaves sealed afterwards. A channel failure is latched the same way as an input overflow (IsInputRejected()), because it is detected inside the transport receive lock a disconnect would take again; the owning worker pass then disconnects with DisconnectReason::ProtocolError. A failed channel is logged as a warning, not reported as an exception: a stranger, a scanner or a client pinned to another key produces exactly that.

The ordered UDP transport’s own header (sequence, acknowledgement, session) stays outside the channel. Forging it can disturb delivery, which an on-path attacker can do anyway, but cannot inject content: every payload byte still has to pass the channel. The same holds for TCP resets.

Compression runs before encryption only on the server-to-client stream; the client’s own stream — logins and tokens — is never compressed, so its length reveals nothing about repeated secrets.

Message buffers

Source/Common/NetBuffer.h defines the shared binary message layer:

Important constant: NETMSG_SIGNATURE = 0x011E9422, the marker every framed message starts with. The buffers hold plaintext; confidentiality and integrity belong to the secure channel above.

NetOutBuffer responsibilities:

NetInBuffer responsibilities:

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. Two layers guard against resource-exhaustion and malformed input:

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, and enum/hash/proto resolution. It does not impose a fixed maximum string length or element count, so absolute length/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 hash_resolver.

Each engine fills its hash storage in advance, from its own resources, and a hash that arrives over the wire is resolved against that storage: the message carries the 64-bit hash only, so the receiver never learns the text from it. A client holds exactly these strings before the first message:

Nothing else is there. A map-instance override on a critter or a dynamic item lives only in the server map-bin, a string the server holds only in server code or composes at runtime is in no client source, and a sound or other non-image resource path is known only when a client-visible prototype property names it. Any of them fails to resolve when it arrives in a synced property, a remote-call argument or a synced ref-type value. The server storage is filled the same way from the server’s resources, and ClientDataValidation rejects a hash a client sends that the server does not hold.

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 hash_resolver; 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.

The engine recovers from this instead of looping on the disconnect:

  1. ClientEngine registers a hash_storage resolve-failure handler. When the client hits an unknown hash on an established connection, the handler writes NetMessage::UnresolvedHash and performs one immediate pending-output flush. ClientConnection::Process still turns the following NetBufferException into 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.
  2. 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 persistent HashReports database 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 pending UnresolvedHash before 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.
  3. 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 after InitData (SendAllReportedHashes). HashList is a count followed by length-prefixed strings.
  4. Clients feed each received string through hash_resolver::to_hashed_string, 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.

Recovery repairs the next connection, not the read that failed: the first read has already thrown, and on the managed bridge that is a script exception at the property read. It is a diagnostic for a missing string, never a way to deliver one; a value that must resolve on arrival belongs in one of the sources listed above.

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 hash_storage::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. stream_decompressor 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:

Factory methods choose transport implementation:

Concrete files include:

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 owns callback registration and dispatch:

The send callback returns the outgoing bytes by value, and every transport owns the buffer it hands to its socket. That is a correctness requirement, not a style choice: the sender behind the callback is a ServerConnection owned by one Player, while the connection object lives in a transport’s shared_ptr, and Dispatch() reaches the send path from any pool thread. A buffer borrowed from the sender would be refilled by a second dispatch, or freed when its owner disconnects, while a transport was still reading it - which is how a partly compressed packet turned into a SIGSEGV inside zlib on the UDP send thread. Disconnect() clears the send-callback flag before disconnecting, so a transport that ticks afterwards stops pulling from a sender that is going away. Each callback is also invoked under the lock that guards it, and Disconnect() drops the callbacks under those same locks on every call, so a destructor that disconnects waits for a call already running on a transport thread instead of racing it.

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:

A logged-in connection is additionally dropped when it stops answering pings: ServerNetwork.ClientPingTime sets the interval, and a connection that has not answered the previous ping when the next one is due is hard disconnected. The in-process interthread transport opts out of this watchdog: its peer lifetime is explicit through the callback channel, while a busy shared process can delay both ends of the ping exchange together. Closing either interthread endpoint still disconnects the other immediately.

Disconnect reasons

Every close records why it happened, because after the fact a connection that went away tells nothing about whether the player quit, the network died, or the server dropped them. ServerConnection stores a DisconnectReason (ServerConnection.h), and HardDisconnect(reason) takes it as a mandatory argument so a new call site cannot forget one:

Reason Cause
None still connected; no close has happened
ClientClosed the transport reported the peer went away — a voluntary quit and a lost network are indistinguishable here, because the client closes its socket without announcing either
InactivityTimeout ServerNetwork.InactivityDisconnectTime elapsed with no inbound message
PingTimeout the previous ping was never answered
LoginTimeout ServerNetwork.LoginTimeout elapsed without pre-login progress
ProtocolError unreadable or unexpected network data, or a failed connection publication
UpdaterError a bad update-file request
ServerShutdown the server is stopping
ScriptRequest Player.HardDisconnect() from a script
LoginFailed login rolled back after a server-side failure
ReplacedByReconnect the same account logged in again and took the session over

The first recorded reason wins. The transport close callback records ClientClosed of its own accord, and it runs after the path that decided to disconnect — without first-wins, that generic cause would bury every specific one. The reason is part of the Closed connection from log line and is readable from scripts through Player.GetDisconnectReason() while OnPlayerLogout handlers run, which is how the game reports a truthful session-end cause instead of assuming every disconnect was a logout. Pinned by ServerConnectionRecordsWhyItWasDisconnected in Source/Tests/Test_NetworkServer.cpp.

NetworkServer starts transport-specific servers through factories:

The listen ports and the client connect endpoint are configured per transport:

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:

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:

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:

UdpTransportOptions controls:

UdpPacketInfo carries parsed packet data:

UdpOrderedChannel owns session state and reliable ordering:

Standalone helpers:

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 EntityModel.md:

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:

A client in the server’s own process takes the interthread transport whenever the server registered its listener on Network.ServerPort. Network.DisableInterthreadCommunication keeps ServerEngine::InitNetworkingJob() from registering it, so such a client connects over UDP or TCP and goes through a real socket.

Build availability is controlled by compile-time feature toggles and platform dependencies. For build toggles and package workflow, see BuildWorkflow.md and BuildToolsPipeline.md.

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:

Change routing

Validation checklist

  1. Run UDP, client, and server network tests relevant to the changed transport, and Test_SecureChannel for any change that touches the byte stream a transport carries.
  2. Validate both connect/accept and disconnect paths.
  3. Validate partial receives and message framing when changing NetInBuffer / NetOutBuffer.
  4. Validate hash resolution/debug-hash behavior across client and server builds.
  5. Validate property synchronization when message layout or property-data serialization changes.
  6. Validate platform-specific transport availability when touching ASIO/WebSocket/socket code.