Repository navigation
Networking
SparkEngine includes an experimental UDP networking system for multiplayer development, with entity replication, client-side prediction, lag compensation, pluggable transports, and dedicated server support. Since protocol version 2 (NET-100) the active NetworkManager wire path authenticates the server with a signed X25519 handshake and seals every post-handshake datagram with ChaCha20-Poly1305 (libsodium); it has not had an independent cryptographic review, and players are authenticated by game code inside the channel, not by the transport.
Source: SparkEngine/Source/Engine/Networking/
Note: Networking is enabled by default (
ENABLE_NETWORKING=ON). First-party endpoints bind to IPv4 loopback by default. Isolated LAN development requires canonical CIDR throughSPARK_NETWORK_BIND_ADDRESSorNetwork.bind_address(for example192.168.1.20/24). The prefix must keep the complete subnet inside RFC1918 space; the exact network and directed-broadcast addresses are rejected, and peers are restricted to concrete hosts in that same subnet. Wildcard, public, documentation/test, multicast, limited-broadcast, CGNAT, IPv4-mapped IPv6, missing-prefix, and alternate textual forms are rejected before socket creation. The captured policy is threaded through gameplay, discovery, collaboration, and live-editor socket lifecycles and filters peer endpoints before packet parsing and every send/retry boundary. Gateway-managed area processes require loopback and reject a conflicting LAN bind. This boundary is containment; authentication and encryption come from the NET-100 secure transport described under SecureChannel, which still awaits independent review. When networking is disabled via-DENABLE_NETWORKING=OFF, a minimalNetworkManagerStubis compiled so the rest of the engine links without errors.
NetworkMessage::localOnly is process-local ownership metadata and is never encoded in the wire format. TERRAFRONT login and registration requests always set it together with the sensitive-payload erasure marker. NetworkManager rechecks the exact destination while holding its API lock at queueing, delayed release, and retransmission boundaries; a non-loopback destination rejects and erases the credential-bearing state before transmission. Selecting a private-LAN bind does not enable remote credential onboarding.
SparkGameMMO's module-owned MMOSessionGate handles account authentication, owned character creation,
world entry, server-timed movement, and nearby greeting interactions over NetworkManager's secure transport.
Login reuses the gateway's guarded authenticator and MMOAccountSystem password hashing. Transport connection
does not grant gameplay authority: every world command checks the connection's authenticated account and
character ownership. The gated module refuses the older client-position upload path.
SparkServer installs the network service before loading game modules. The MMO module adopts a host-owned
listener or starts its own shared-headless listener on its first update, and unregisters its session handlers
before destroying account/player services. Network clients use the mmo_session_* console commands;
the login UI remains an offline showcase. This small-area slice does not supply persistent accounts,
cross-area handoff, or collision-complete movement.
The gate's network handlers may run on the DedicatedServer tick thread inside SparkServer, so they only
copy datagrams into a bounded inbox that the module drains on the game thread.
MMOIntegratedWorld_TwoClientSessionGate runs the real server/two-client process scenario, including
bad-credential and foreign-character refusals and peer-observed movement/interactions. It is local evidence,
not exact-commit CI. See the module guide.
The networking subsystem is composed of several layered modules that work together to provide a complete multiplayer stack:
┌────────────────────────────────────────────────────────────────────┐
│ Game Code │
│ (registers handlers, sends messages, queries stats) │
├────────────────────────────────────────────────────────────────────┤
│ DedicatedServer │
│ (tick loop, map rotation, local admin, LAN discovery, match state)│
├────────────────────────────────────────────────────────────────────┤
│ NetworkManager │
│ (message routing, entity replication, connection management) │
├────────────────────────────────────────────────────────────────────┤
│ ClientPrediction │ LagCompensator │ NetworkSecurity │
│ (input buffering, │ (history buffer, │ (single-use connection │
│ reconciliation) │ hitbox rewind) │ tokens) │
├────────────────────┴─────────────────┬┴───────────────────────────┤
│ NetworkStack │
│ (transport selection + connection-token registry) │
├────────────────────────────────────────────────────────────────────┤
│ ITransport (abstract) │
│ ┌───────────────────┬───────────────────────┐ │
│ │ UDPTransport │ SteamTransport │ │
│ │ (BSD/Winsock) │ (stub, future SDK) │ │
│ └───────────────────┴───────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
| File | Responsibility |
|---|---|
NetworkManager.h |
Core singleton: message routing, entity replication, connection lifecycle |
ITransport.h |
Abstract transport interface for packet I/O |
UDPTransport.h |
Concrete UDP socket transport (default) |
SteamTransport.h |
Stub transport for future Steam Networking Sockets |
ClientPrediction.h |
Client-side prediction and server reconciliation |
NetworkSecurity.h |
Single-use, expiring CSPRNG connection-token registry; not encryption or peer authentication |
NetworkEncryption.h |
libsodium-backed RFC 8439 ChaCha20-Poly1305 SecureChannel (seals every post-handshake NetworkManager datagram; not independently reviewed) plus rate limiter |
SecureHandshake.h |
Signed-ephemeral X25519 key agreement carried in Connect / ConnectAccepted
|
NetworkTrustStore.h |
Server identity file, client ServerTrust (pinned or trust-on-first-use known_hosts), NetworkSecurityConfig
|
NetworkIntegration.h |
NetworkStack -- transport selection plus connection-token registry |
DedicatedServer.h |
Headless server: tick loop, local admin commands, map rotation, LAN broadcast |
AreaServer.h |
Per-area server process for scalable multiplayer worlds |
WorldServer.h |
Central coordinator for area-based multiplayer architecture |
Area Server Architecture — For MMO-scale multiplayer with multiple area servers coordinated by a WorldServer, see Area Server Architecture.
All networking types reside in Spark::Net. The ClientPrediction class resides in Spark.
namespace Spark::Net
{
using ClientID = uint32_t;
using SequenceNumber = uint32_t;
using NetworkTime = float;
constexpr ClientID INVALID_CLIENT = 0;
constexpr uint16_t DEFAULT_PORT = 27015;
}| Value | Description | Use Case |
|---|---|---|
Unreliable |
Fire-and-forget, no delivery guarantee | Position updates, movement data |
Reliable |
Guaranteed delivery with ordering | Chat messages, state changes |
ReliableOrdered |
Guaranteed delivery, strict in-order | Important game events, score updates |
| Value | Description |
|---|---|
None |
Not connected |
Server |
Acting as authoritative server |
Client |
Acting as client connected to server |
| Value | Description |
|---|---|
Disconnected |
No active connection |
Connecting |
Handshake in progress |
Connected |
Fully connected and ready |
Disconnecting |
Graceful disconnect in progress |
All messages are tagged with a MessageType enum that determines routing:
enum class MessageType : uint16_t
{
// Connection lifecycle
Connect = 1,
ConnectAccepted,
ConnectRejected,
Disconnect,
Heartbeat,
// Entity replication
EntitySpawn,
EntityDestroy,
EntityStateUpdate,
EntityRPC,
// Input prediction
ClientInput,
InputAck,
// Game logic
ChatMessage,
GameStateSync,
MatchStart,
MatchEnd,
PlayerRespawn,
ScoreUpdate,
// Custom user-defined messages start at 1000
UserDefined = 1000
};struct NetworkMessage
{
MessageType type;
ChannelType channel = ChannelType::Unreliable;
ClientID senderID = INVALID_CLIENT;
SequenceNumber sequence = 0;
std::vector<uint8_t> payload;
float timestamp = 0.0f;
};PacketValidator::ValidateStringFields is enforced, not merely declared (2026-09 sweep). A
MessageSchema states where its text lives through stringFieldOffset, a byte offset into the
payload at which a NetBuffer length-prefixed string begins; Spark::Net::NO_STRING_FIELDS marks the
payload as opaque binary that is never screened. The former sanitizeStrings bool was removed. The
built-in ChatMessage wire format is NetBuffer strings (sender name, then text) starting at payload
offset 0. Text is validated as UTF-8 (the signed-char defect that rejected any byte above 0x7F is
fixed). The string: N counter printed by packet.stats is a real count -- before the sweep it was a
permanent zero printed as if the check had run and passed.
A module whose payload prepends its own header must register its own MessageType in the
UserDefined range (1000+) rather than re-registering the built-in type's schema: RegisterSchema
replaces a shared, process-wide entry that is never restored on module shutdown, so every other user
of that type — the engine included, after the module unloads — would keep validating against the
module's layout. GameModules/SparkGameMMO/Source/Chat/MMOChatSystem.cpp follows this rule:
kMMOChatMessageType = MessageType::UserDefined + 1 with stringFieldOffset = 1 for its
uint8 channel header, and the built-in MessageType::ChatMessage schema is left untouched.
PacketValidator::ValidateStringFields is public, but the screen is applied by the
receive path; no in-tree handler calls it a second time on a decoded payload. Tests:
Tests/TestPacketValidator.cpp and Tests/TestPacketValidatorReal.cpp execute the shipped
validator.
The NetBuffer class provides type-safe binary serialization for network payloads:
| Write Method | Read Method | Data Type |
|---|---|---|
WriteUint8(uint8_t) |
ReadUint8() |
1 byte unsigned |
WriteUint16(uint16_t) |
ReadUint16() |
2 byte unsigned |
WriteUint32(uint32_t) |
ReadUint32() |
4 byte unsigned |
WriteFloat(float) |
ReadFloat() |
4 byte float |
WriteString(const std::string&) |
ReadString() |
Length-prefixed string |
WriteVector3(const XMFLOAT3&) |
ReadVector3() |
3 floats (12 bytes) |
WriteBytes(const void*, size_t) |
ReadBytes(void*, size_t) |
Raw byte range |
Safety methods:
| Method | Description |
|---|---|
CanRead(size_t bytes) |
Check if bytes can be read without overrun |
HasError() |
True if any read overran the buffer |
IsValid() |
Opposite of HasError()
|
RemainingBytes() |
Bytes left to read |
GetReadPosition() |
Current read cursor position |
Reset() |
Clear data and reset read position |
// Writing a player position update
NetBuffer buf;
buf.WriteUint32(playerNetworkID);
buf.WriteVector3(position);
buf.WriteVector3(velocity);
buf.WriteFloat(yaw);
// Reading it back
uint32_t id = buf.ReadUint32();
XMFLOAT3 pos = buf.ReadVector3();
XMFLOAT3 vel = buf.ReadVector3();
float yaw = buf.ReadFloat();
if (buf.HasError()) { /* handle truncated packet */ }Entities with replicated state are tracked by the NetworkManager via ReplicatedEntity:
struct ReplicatedProperty
{
std::string name;
enum class Type { Int, Float, Vector3, String, Bool } type;
std::function<void(NetBuffer&)> serialize;
std::function<void(NetBuffer&)> deserialize;
bool dirty = false;
};
struct ReplicatedEntity
{
uint32_t networkID;
ClientID ownerID;
std::string entityType;
std::vector<ReplicatedProperty> properties;
XMFLOAT3 position{0, 0, 0};
XMFLOAT3 rotation{0, 0, 0};
XMFLOAT3 velocity{0, 0, 0};
float lastUpdateTime = 0.0f;
bool needsFullSync = true;
};// Register an entity for replication (server)
uint32_t netId = network.RegisterReplicatedEntity(entity);
// Mark a property as changed (triggers delta update on next replication tick)
network.MarkPropertyDirty(netId, "health");
// Remove from replication
network.UnregisterReplicatedEntity(netId);
// Get a replicated entity by network ID
ReplicatedEntity* ent = network.GetReplicatedEntity(netId);
// Full sync to a newly connected client
network.SendFullEntitySync(newClientId);
// Manual serialization/deserialization
NetBuffer buf;
network.SerializeEntityState(netId, buf);
network.DeserializeEntityState(buf);The server replicates entity state at a configurable rate (default: 20 Hz, controlled by m_replicationInterval = 0.05f). Only properties marked dirty are sent in delta updates; full syncs are sent when needsFullSync is true (e.g., on initial spawn or when a new client connects).
The ClientPrediction class (in Spark namespace) provides responsive FPS movement by predicting locally while reconciling with authoritative server state.
struct PredictedInput
{
uint32_t sequenceNumber = 0;
float timestamp = 0.0f;
DirectX::XMFLOAT3 moveDirection{0, 0, 0};
float lookYaw = 0.0f;
float lookPitch = 0.0f;
bool jump = false;
bool crouch = false;
bool sprint = false;
bool fire = false;
bool reload = false;
bool interact = false;
};struct PredictedState
{
uint32_t lastProcessedInput = 0;
DirectX::XMFLOAT3 position{0, 0, 0};
DirectX::XMFLOAT3 velocity{0, 0, 0};
float yaw = 0.0f;
float pitch = 0.0f;
bool isGrounded = true;
bool isCrouching = false;
bool isSprinting = false;
};Client Tick:
1. RecordInput(input) --> assigns sequence number, stores in buffer
2. ApplyPrediction(state, input, dt) --> runs movement locally
3. Send input to server
Server State Received:
4. Reconcile(serverState, dt) --> snap to server, re-apply unACKed inputs
ClientPrediction prediction;
prediction.SetMaxPendingInputs(128);
prediction.SetSmoothCorrection(true, 10.0f);
// Optionally override the movement simulator
prediction.SetMovementSimulator(
[](PredictedState& state, const PredictedInput& input, float dt) {
// Custom FPS movement logic
});
// Each tick
PredictedInput input;
input.moveDirection = GetInputDirection();
input.jump = IsJumpPressed();
uint32_t seq = prediction.RecordInput(input);
PredictedState state = prediction.GetState();
prediction.ApplyPrediction(state, input, deltaTime);
// On server correction
prediction.Reconcile(serverState, fixedDeltaTime);
// Query
size_t pending = prediction.GetPendingInputCount();
float correction = prediction.GetLastCorrectionMagnitude();When reconciliation produces a large position correction, the system can interpolate smoothly rather than snapping:
prediction.SetSmoothCorrection(true, 10.0f); // speed = 10 means ~0.1s to convergeThe correction offset is maintained internally and blended toward zero each frame.
For hit detection in fast-paced FPS gameplay, the server maintains a sliding window of entity position history:
struct HistorySnapshot
{
float timestamp;
struct EntityState
{
uint32_t networkID;
XMFLOAT3 position;
XMFLOAT3 rotation;
XMFLOAT3 boundsMin; // AABB min for hitbox
XMFLOAT3 boundsMax; // AABB max for hitbox
};
std::vector<EntityState> entities;
};
class LagCompensator
{
public:
void RecordSnapshot(const HistorySnapshot& snapshot);
bool RewindToTime(float targetTime, HistorySnapshot& outSnapshot) const;
void SetMaxHistoryDuration(float seconds); // Default: 1.0s
void Clear();
};- Each server tick, a
HistorySnapshotis recorded containing all entity positions and AABB hitboxes - When processing a shot, the server calls
RewindToTime(shooterTimestamp)to get the world state at the time the shooter fired - Hit detection is performed against the rewound positions
- Default history duration is 1 second (configurable via
SetMaxHistoryDuration)
LagCompensator& lag = network.GetLagCompensator();
lag.SetMaxHistoryDuration(1.0f);
// Each tick (server)
HistorySnapshot snap;
snap.timestamp = network.GetServerTime();
// ... populate entity states ...
lag.RecordSnapshot(snap);
// When processing a shot
HistorySnapshot rewound;
if (lag.RewindToTime(clientShootTimestamp, rewound))
{
// Perform hit detection against rewound.entities
}The ITransport interface decouples NetworkManager from any specific socket implementation:
class ITransport
{
public:
virtual bool Initialize(uint16_t port) = 0;
virtual void Shutdown() = 0;
virtual bool Send(const uint8_t* data, size_t size,
const std::string& address, uint16_t port) = 0;
virtual int Receive(uint8_t* buffer, size_t bufferSize,
std::string& fromAddress, uint16_t& fromPort) = 0;
virtual bool IsReady() const = 0;
virtual std::string GetTransportName() const = 0;
};The default transport uses platform BSD/Winsock UDP sockets:
- Creates a non-blocking UDP socket
- Enlarges OS send/receive buffers to 64 KB each for game traffic
- Binds exactly to a requested concrete port, or to an OS-assigned ephemeral port when given port 0
- Uses IPv4 loopback unless one canonical RFC1918 interface/prefix is selected
- Cross-platform: Winsock on Windows, POSIX sockets on Linux/macOS
A placeholder for future Steam Networking Sockets integration. Currently all methods return failure. When the Steamworks SDK is linked, this will use ISteamNetworkingSockets for relay-based, NAT-traversing packet I/O.
The NetworkStack class combines transport selection with a connection-token registry. It is not the active NetworkManager wire path and applies no packet encryption:
struct NetworkStackConfig
{
enum class TransportType { UDP, Steam };
TransportType transport = TransportType::UDP;
std::string serverAddress = "127.0.0.1";
uint16_t serverPort = 27015;
};NetworkStack stack;
NetworkStackConfig config;
config.transport = NetworkStackConfig::TransportType::UDP;
stack.Initialize(config);
NetworkSecurity::Token token{};
if (stack.GenerateConnectionToken(token)) // false on CSPRNG failure or before Initialize()
{
bool accepted = stack.ValidateToken(token); // single use; false before Initialize()
}
ITransport* transport = stack.GetTransport();Issues single-use connection tokens and matches them. Tokens come from NetworkEncryption's GenerateConnectionToken() (OS CSPRNG) and are compared with its constant-time ValidateToken(). Both fail closed: a CSPRNG failure issues and records nothing, and unknown, reused, or expired tokens are rejected. A token only proves that a peer echoed a value this process issued; it does not authenticate or encrypt the UDP connection.
static constexpr float CONNECTION_TOKEN_LIFETIME = 30.0f; // 30 second expiry
using Token = ConnectionToken; // TOKEN_SIZE (16) bytes| Method | Description |
|---|---|
GenerateConnectionToken(outToken) |
Issue and record a CSPRNG token; returns false (token zeroed) on CSPRNG failure |
ValidateConnectionToken(token) |
Constant-time match against pending tokens; consumes the token on success |
The repeating-key XOR "encryption" prototype (PacketEncrypt/Encrypt/GetEncryptionKey, NetworkStack::Encrypt/Decrypt, and the enableEncryption flags in NetworkStackConfig and [Network] engine settings) was deleted under NET-100. Tests/TestNetworkSecurity.cpp static-asserts that the API stays gone, and Tests/Tools/test_network_security_csprng.py (label network-security) fails if XOR transform code returns to these headers.
Warning: The
NetworkManagerUDP path (protocol version 3) authenticates the server and seals every datagram after the handshake, but NET-100 remains open: the composition has not been independently reviewed, anti-amplification cookies are missing, and SparkGateway tickets are not yet bound to the UDP session. Its primitives are libsodium's (OD-06).
Holds thin wrappers over libsodium's RFC 8439 ChaCha20-Poly1305 and HKDF-SHA256, the SecureChannel packet channel built on them, the fail-closed token helpers, and an independent rate limiter. NetworkManager keeps one SecureChannel per peer (m_secureChannels), created by the handshake below, and seals every non-handshake message with it at transmit time (SendFrameTo); see Networking Wire Format for the frame rules and nonce discipline.
constexpr size_t SESSION_KEY_SIZE = 32; // shared secret / ChaCha20 key
constexpr size_t AEAD_NONCE_SIZE = 12; // RFC 8439 96-bit nonce
constexpr size_t AEAD_TAG_SIZE = 16; // full Poly1305 tag
constexpr size_t TOKEN_SIZE = 16; // CSPRNG connection token
constexpr uint8_t SECURE_TRANSPORT_VERSION = 1; // only accepted wire version
constexpr size_t SECURE_HEADER_SIZE = 1 + 1 + 8; // [version][key epoch][sequence u64 LE]| Function / type | Description |
|---|---|
EnsureSodium() |
Runs sodium_init() once per process; false means every crypto call fails closed |
GenerateSessionKey(outKey) / GenerateConnectionToken(outToken)
|
libsodium randombytes_buf; return false (output zeroed) when libsodium is unavailable |
ValidateToken(expected, received) |
Constant-time token comparison (sodium_memcmp) |
ChaCha20Poly1305Seal / ChaCha20Poly1305Open
|
Raw RFC 8439 AEAD (crypto_aead_chacha20poly1305_ietf_*); the caller owns nonce uniqueness |
SecureChannel::Seal / Open
|
Per-direction HKDF keys, sender-owned sequence numbers, authenticated replay window, epoch ratchet; Open returns an OpenResult drop reason |
Nonces are [key epoch][0 0 0][sequence u64 LE]: every epoch has its own key and the sender's sequence only increases, so a (key, nonce) pair never repeats. Keys are wiped with sodium_memzero.
Tests: Tests/TestNET100TransportReal.cpp (Transport_*) and Tests/TestNET100Libsodium.cpp (Transport_Libsodium_*). No cryptographic primitive is implemented in Engine/Networking; the SparkNetworkSecurityCsprngContract CTest fails if a ChaCha20, Poly1305 or HMAC implementation reappears there. libsodium is the pinned ThirdParty/Security/libsodium submodule (1.0.22), built by cmake/SparkLibsodium.cmake.
SecureHandshake.h produces the shared secret SecureChannel needs, with libsodium primitives only. The server holds a long-term Ed25519 identity (GenerateServerIdentity) whose public key the client pins. The client is anonymous at the transport layer. One round trip:
| Message | Size | Layout (little-endian) |
|---|---|---|
| ClientHello | 55 bytes | [magic u32 = 0x484E5053][NETWORK_PROTOCOL_VERSION u16][suite u8 = 1][client X25519 pub 32][client nonce 16] |
| ServerHello | 145 bytes | [suite u8][server Ed25519 pub 32][server X25519 pub 32][server nonce 16][Ed25519 signature 64] |
Both sides hash the transcript th = SHA-256("SPNH-v3" || ClientHello || accept prefix || ServerHello without the signature) and the server signs th; the accept prefix is the 10 bytes of ConnectAccepted before the ServerHello ([client id u32][server time f32][echoed version u16], CONNECT_ACCEPT_PREFIX_SIZE). Version, suite and the assigned client id sit inside the signed transcript, so rewriting any of them breaks the signature; the client adopts its id only from a verified prefix. A small-order client ephemeral key is refused by comparison against libsodium's blocklist (IsLowOrderX25519PublicKey) before any curve operation, and the server derives the shared secret before it signs, so a refused hello never costs an Ed25519 signature. The session secret is HKDF-SHA256(salt = th, ikm = X25519(ephemeral, peer ephemeral), info = "spark-net-100 session v3"), and it is the only input to SecureChannel. Fresh ephemeral keys and nonces on both sides make every session secret unique, so channel nonces never repeat across sessions. Ephemeral secrets and intermediate keys are wiped with sodium_memzero, and a ClientHandshake is single use.
| Function / type | Description |
|---|---|
ClientHandshake::Begin(version) |
Generates the ephemeral key and nonce, returns the ClientHello |
RespondToClientHello(hello, acceptPrefix, identity) |
Validates the hello, returns the ServerHello (signed over the accept prefix too) and the server SecureChannel; allocates nothing for a rejected hello |
ClientHandshake::Finish(acceptPrefix, serverHello, pinnedKey) |
Checks suite and pinned identity, verifies the signature over the transcript including the accept prefix, returns the client SecureChannel
|
IsLowOrderX25519PublicKey(key) |
Comparison-only small-order check; HandleConnect and RespondToClientHello call it before any curve work |
ConnectRateLimiter (ConnectRateLimiter.h) |
Per-source-IPv4 token bucket for unadmitted Connects (NetworkSecurityConfig::connectRate, default burst 16, 4/s); excess is dropped unanswered |
HandshakeError |
Malformed, UnsupportedVersion, UnsupportedSuite, BadSignature, ServerIdentityMismatch, WeakSharedSecret (low-order X25519 point), CsprngFailure, InvalidState
|
Tests: Tests/TestNET100Handshake.cpp (Transport_Handshake_*, CTest NetworkSecurity_Transport_Handshake, exact count 11): interoperating channels, wrong pinned key, every ServerHello byte flip, every accept-prefix byte flip, every truncation and extension, version and suite downgrade, a replayed ServerHello, every small-order client point (checked against libsodium as an oracle), and deterministic rejection of malformed hellos; Transport_RateLimit_* (CTest NetworkSecurity_Transport_RateLimit) covers the Connect budget. The server lists a Securing slot only in GetClientSlots(): GetClients() returns admitted (Connected) clients, and SendToClient refuses a non-Connected slot everything but ConnectAccepted/ConnectRejected. NetworkManager carries them in production: the ClientHello is the Connect payload and the ServerHello follows the echoed version in ConnectAccepted; the server holds the new slot in state Securing until the client's sealed ClientFinished arrives. The server's identity and the client's trust come from NetworkSecurityConfig (NetworkTrustStore.h): StartServer refuses without an identity, Connect refuses without a usable pin or known_hosts path, and UseDefaultSecurityConfig fills them from <user data>/net. Wired tests: Tests/TestSecureTransportWired.cpp (CTest NetworkSecureTransportWired) and Tests/TestNET100TrustStore.cpp (Transport_TrustStore_*).
class RateLimiter
{
public:
explicit RateLimiter(uint32_t maxPacketsPerSecond = 100,
uint32_t burstAllowance = 20);
bool AllowPacket(uint64_t addressHash);
void ResetClient(uint64_t addressHash);
void Clear();
uint32_t GetPacketCount(uint64_t addressHash) const;
};Uses a sliding window approach per source IP:port hash. Rejects packets exceeding the configured rate.
class ReplayProtection
{
public:
static constexpr size_t WINDOW_SIZE = 256;
bool Accept(uint64_t sequence); // Returns false for duplicate/too-old values
void Reset();
};Tracks received sequence numbers in a 256-entry sliding window. Without authenticated packets, an attacker can forge sequence values; this helper is not a production replay-defense boundary.
The DedicatedServer class provides a complete headless server:
struct ServerConfig
{
// Identity
std::string serverName = "Spark Dedicated Server";
std::string motd; // Message of the day
// Network
uint16_t port = DEFAULT_PORT; // 27015
int maxClients = 32;
float tickRate = 60.0f; // Ticks per second
float clientTimeoutSeconds = 30.0f;
float heartbeatIntervalSeconds = 1.0f;
NetworkEndpointPolicy endpointPolicy = CaptureNetworkEndpointPolicy();
// Game
GameModeType gameMode = GameModeType::Deathmatch;
std::string customGameModeName;
int scoreLimit = 50;
float timeLimitMinutes = 15.0f;
int roundCount = 1;
bool friendlyFire = false;
bool autoBalanceTeams = true;
// Map rotation
std::vector<std::string> mapRotation;
bool randomizeMapOrder = false;
// Administration: trusted in-process only. Remote RCON is permanently
// unavailable in stable-v1 (OD-05), so there is no password or port field.
bool enableLogging = true;
std::string logFilePath = "server.log";
// LAN discovery
bool enableLanBroadcast = false;
uint16_t lanBroadcastPort = 27016;
// Performance
bool enableAntiCheat = false;
float replicationRate = 20.0f; // Entity replication Hz
int snapshotHistorySize = 64; // Lag compensation snapshots
};| Value | Description |
|---|---|
Deathmatch |
Free-for-all deathmatch |
TeamDeathmatch |
Team-based deathmatch |
CaptureTheFlag |
Capture the flag mode |
Domination |
Zone control mode |
SearchAndDestroy |
Attack/defend objectives |
FreeForAll |
Free-for-all variant |
Custom |
Custom game mode (use customGameModeName) |
DedicatedServer server;
ServerConfig config;
config.serverName = "My FPS Server";
config.port = 27015;
config.maxClients = 16;
config.tickRate = 60.0f;
config.gameMode = GameModeType::TeamDeathmatch;
config.mapRotation = {"dm_warehouse", "dm_canyon", "dm_rooftops"};
// Start with background tick loop
server.Start(config);
// Or: initialize + drive externally
server.InitializeOnly(config);
while (running) { server.Tick(deltaTime); }
// Graceful shutdown
server.Stop();struct ServerCallbacks
{
std::function<void()> onServerStarted;
std::function<void()> onServerStopped;
std::function<void(ClientID, const std::string&)> onClientConnected;
std::function<void(ClientID, const std::string&)> onClientDisconnected;
std::function<void(const std::string&)> onMapChanged;
std::function<void(const std::string&)> onChatMessage;
std::function<void(const std::string&, const std::string&)> onRconCommand;
std::function<void(const std::string&)> onLogMessage;
};There is no remote RCON listener, and remote administration is permanently
unavailable in stable-v1 (owner decision OD-05). ExecuteRcon is for trusted
in-process host/control code only; network chat never dispatches admin commands,
and ServerConfig has no RCON password or port field.
// Register custom local administration commands
server.RegisterRconCommand("restart", "Restart the current match",
[&](const std::vector<std::string>& args) -> std::string {
server.EndMatch();
server.StartMatch();
return "Match restarted.";
});
// Dispatch from trusted host code
std::string response = server.ExecuteRcon("kick 3 cheating");Built-in commands are registered automatically: help, status, kick, ban, map, say, players, endmatch, and nextmap. There is no quit command; the owning control thread must call Stop().
// Server side: broadcast presence
server.StartLanBroadcast(); // Explicit opt-in; emits discovery metadata only
// Client side: discover servers
auto servers = DedicatedServer::DiscoverLanServers(27016, 2000);
for (const auto& info : servers)
{
// info.serverName, info.mapName, info.currentPlayers, info.maxPlayers, info.ping
}struct ServerStats
{
float uptimeSeconds = 0.0f;
uint64_t totalTicksProcessed = 0;
float averageTickMs = 0.0f;
float peakTickMs = 0.0f;
uint32_t currentPlayers = 0;
uint32_t peakPlayers = 0;
uint64_t totalBytesIn = 0;
uint64_t totalBytesOut = 0;
uint32_t totalConnectionsServed = 0;
float currentTickRate = 0.0f;
std::string currentMap;
int currentMapIndex = 0;
float matchTimeRemaining = 0.0f;
int currentRound = 1;
};static NetworkManager& GetInstance();| Method | Description |
|---|---|
Initialize() |
Initialize platform sockets. Must be called first. |
Shutdown() |
Release all resources. |
StartServer(port, maxClients) |
Listen on UDP port (default 27015, max 32 clients) |
StopServer() |
Stop server, disconnect all clients |
Connect(address, port, name) |
Connect to a server as client |
Disconnect() |
Disconnect from server or shut down |
Update(deltaTime) |
Process incoming/outgoing messages (call each frame) |
| Method | Description |
|---|---|
SendMessage(msg) |
Send to connected server (client) or broadcast (server) |
SendToClient(id, msg) |
Send to specific client (server only) |
SendToAll(msg) |
Broadcast to all connected clients |
SendToAllExcept(id, msg) |
Broadcast excluding one client |
BroadcastMessage(msg) |
Alias for SendToAll
|
using MessageHandler = std::function<void(const NetworkMessage&)>;
void RegisterHandler(MessageType type, MessageHandler handler);| Method | Return Type | Description |
|---|---|---|
GetRole() |
NetworkRole |
Server, Client, or None |
GetConnectionState() |
ConnectionState |
Current connection state |
GetLocalClientID() |
ClientID |
Local client's assigned ID |
GetServerTime() |
float |
Server clock time |
GetStats() |
const NetworkStats& |
Bandwidth/latency stats |
IsInitialized() |
bool |
Whether Initialize() succeeded |
GetClients() |
unordered_map<ClientID, ClientInfo> |
Admitted (Connected) clients only (server) |
GetClientSlots() |
unordered_map<ClientID, ClientInfo> |
Every slot, including Securing ones (diagnostics) |
struct NetworkStats
{
float ping = 0.0f; // Round-trip time (ms)
float jitter = 0.0f; // Ping variance (ms)
float packetLoss = 0.0f; // 0.0 to 1.0
uint64_t bytesSent = 0;
uint64_t bytesReceived = 0;
uint32_t packetsSent = 0;
uint32_t packetsReceived = 0;
uint32_t packetsDropped = 0;
float bandwidthUp = 0.0f; // KB/s
float bandwidthDown = 0.0f; // KB/s
};struct ClientInfo
{
ClientID id = INVALID_CLIENT;
std::string name;
ConnectionState state = ConnectionState::Disconnected;
NetworkStats stats;
float lastHeartbeatTime = 0.0f;
uint32_t playerEntityNetworkID = 0;
};- Client sends
MessageType::Connectcarrying its ClientHello (no player name) - Server charges the source's Connect budget, validates version, suite, key and capacity, assigns a
ClientIDand holds the slotSecuring - Server sends
ConnectAccepted(assigned ID + signed ServerHello), orConnectRejectedwith reason - Client verifies the signature and sends its name in a sealed
ClientFinished; the server marks the slotConnectedand only then surfaces it and syncs entities (SendFullEntitySync)
- Default interval: 1 second (
m_heartbeatInterval = 1.0f) - Connection timeout: 10 seconds (
m_connectionTimeout = 10.0f) -
UpdateHeartbeat()is called each frame; timed-out clients are disconnected
The reliable channel provides guaranteed delivery with duplicate detection and ordered delivery:
- ACK tracking: Receiver tracks the highest received sequence number and a 32-bit bitfield encoding the previous 32 sequences. ACKs are sent at ~30 Hz.
-
Retransmission: Unacknowledged messages are retransmitted with exponential backoff (base interval doubles each retry, capped at 8x). Configurable via
SetMaxReliableRetries()(default: 10). - Duplicate detection: Receiver maintains a set of recently received sequence numbers (pruned after 30 seconds). Duplicate packets are silently dropped.
-
Ordered delivery:
ReliableOrderedmessages carry their own per-peer ordered sequence (protocol v3), separate from the reliability/ACK sequence, and are delivered in that order. Out-of-order packets are held until the gap is filled; a copy older than the next expected ordered sequence is ACKed and dropped. Before v3 both channels shared one counter, so any Reliable message (such as a client'sClientFinished) left a gap and ordered delivery stalled forever (Tests/TestReliableOrderedSequenceReal.cpp). - RTT estimation: Jacobson/Karels algorithm (RFC 6298) computes smoothed RTT and variance. Karn's algorithm skips retransmitted packets for RTT samples.
-
Connection failure: After
m_maxReliableRetriesretransmissions, the message is dropped andpacketsDroppedis incremented.
The ValidateHit() method integrates lag compensation with hit detection:
- Server receives a hitscan request from a client with the client's timestamp
- Rewinds entity positions to
clientTimestamp - halfRTTusingLagCompensator::RewindToTime() - Interpolates hitbox positions between bracketing snapshots for sub-frame accuracy
- Performs a ray-AABB intersection test against rewound hitboxes
- Returns
HitValidationResultwith hit status, entity ID, and hit point
This ensures clients see fair hit registration despite network latency.
- Samples are taken via
std::chrono::steady_clock -
m_bytesSentSinceSampleandm_bytesReceivedSinceSampleaccumulate between samples - Results are stored in
m_stats.bandwidthUpandm_stats.bandwidthDown(KB/s)
| Component | Thread Safety | Details |
|---|---|---|
NetworkManager |
Queue mutex |
m_queueMutex protects m_incomingQueue and m_outgoingQueue; m_handlerMutex protects handler registration |
DedicatedServer |
Internal mutexes | Local admin registry (m_rconMutex), bans (m_banMutex), logging (m_logMutex). Tick loop runs on m_tickThread. |
UDPTransport |
Not thread-safe | Socket operations should be called from the network thread only |
NetworkSecurity |
Not thread-safe | Pending-token list is not mutex-protected; call from single thread |
ClientPrediction |
Not thread-safe | Call from main game thread only |
RateLimiter |
Not thread-safe | Access from network thread only |
-
Initialize(),StartServer(),Connect()returnbool-- false on failure - Socket creation failures log errors and return false
- Invalid message deserialization sets
NetBuffer::m_errorflag -
CanRead()prevents buffer overruns during deserialization - Connection token validation is constant-time to prevent timing attacks
- Rate limiter rejects packets exceeding the per-client threshold silently
| Parameter | Default | Description |
|---|---|---|
| Replication rate | 20 Hz | m_replicationInterval = 0.05f |
| Heartbeat interval | 1.0s | m_heartbeatInterval |
| Connection timeout | 10.0s | m_connectionTimeout |
| Reliable retransmit | 0.5s | m_reliableRetransmitInterval |
| Max reliable retries | 10 | m_maxReliableRetries |
| Max clients | 32 | m_maxClients |
| Lag history | 1.0s | m_maxHistoryDuration |
| Socket buffer | 64 KB | Send and receive buffers |
| Rate limit | 100 pkt/s |
RateLimiter default |
| Burst allowance | 20 pkt | Extra packets in short bursts |
| Replay window | 256 | ReplayProtection::WINDOW_SIZE |
| Max pending inputs | 128 | ClientPrediction::m_maxPendingInputs |
| Server tick rate | 60 Hz | ServerConfig::tickRate |
net_status # Show NetworkManager connection state and role
net_clients # List connected clients with stats (server only)
net_stats # Show bandwidth, ping, jitter, packet loss
prediction_status # Show prediction pending count and correction magnitude
server_status # Show DedicatedServer uptime, players, map, match state
| Symptom | Possible Cause | Solution |
|---|---|---|
Initialize() returns false |
ENABLE_NETWORKING=OFF |
Rebuild with -DENABLE_NETWORKING=ON
|
| Connection timeout | Wrong concrete interface or blocked UDP 27015 | Verify bind_address and local firewall rules |
| High packet loss | Network congestion or buffer overflow | Increase socket buffer size; reduce replication rate |
| Rubber-banding | Large prediction corrections | Tune SetSmoothCorrection speed; reduce server tick interval |
| Stale entity state | Property not marked dirty | Call MarkPropertyDirty() after modifying replicated properties |
| Prototype token match fails | Token expired (30s lifetime) | Check the local prototype lifecycle; it is not peer authentication |
| Prototype packet tag mismatch | Different prototype state or changed bytes | Check prototype inputs; do not treat the result as secure |
SendMessage compile error on Windows |
Windows macro conflict | The header #undef SendMessage handles this automatically |
When networking is disabled, NetworkManagerStub is provided:
class NetworkManagerStub
{
public:
static NetworkManagerStub& GetInstance();
bool Initialize() { return false; }
void Shutdown() {}
bool StartServer(...) { return false; }
void StopServer() {}
bool Connect(...) { return false; }
void Disconnect() {}
void Update(float) {}
NetworkRole GetRole() const { return NetworkRole::None; }
ConnectionState GetConnectionState() const { return ConnectionState::Disconnected; }
bool IsInitialized() const { return false; }
};All calls are no-ops. GetRole() always returns None. This allows game code to compile and run in single-player mode without #ifdef guards everywhere.
- Entity Component System -- NetworkIdentity component
- Gameplay Systems -- Multiplayer game modes
- Event System -- Network event handling
- Scene Management -- Networked scene transitions
- Physics -- Server-authoritative physics
- Animation -- Replicated animation states
- Input System -- Client input processing and prediction
Published from 614bca17b41e. Edit the canonical source in wiki/.
- Documentation
- Docs route
- Wiki index
- Guides
- Tutorials
- Samples
- Examples
- API Reference
- API route
- Reference
- Build Guide
- Dependencies
- FAQ
- Changelog
- Roadmap
- Contributing
- Code of Conduct
- Home
- FAQ
- Getting Started
- Quick-Start Tutorial
- Making Your First Game
- Making Your First Multiplayer Game
- Artist Workflow Guide
- Editor Walkthrough
- Migration Guide
- How SparkEngine Works
- Architecture Overview
- Engine Architecture Flowchart
- Creating a Game Module
- Game Modules (catalog)
- Entity Component System
- Rendering and Graphics
- Physics
- Cloth Simulation
- Audio
- Input System
- Camera System
- Scripting with AngelScript
- Visual Scripting
- AI and Navigation
- Animation
- 2D Systems
- Networking
- Dedicated Server
- Multiplayer Quick Start
- Area Server Architecture
- Scene Management
- Large World Support
- Collaborative Editing
- Coroutine System
- Event System
- Event Response System
- Job System
- UI System
- UI Layout Extensions
- Localization
- Dialogue System
- Destruction System
- Replay System
- Achievement System
- Loading System
- Mod System
- Content Delivery
- Tween System
- Memory Integrity
- Gameplay Systems
- Terrain and Procedural Generation
- Save System
- Persistence System
- Day Night Cycle and Weather
- Cinematic Sequencer
- Runtime Prefabs
- SparkEditor
- Editor Tutorials
- SparkConsole
- SparkDaemon
- Shader Pipeline
- Asset Pipeline
- Asset Validation
- Asset Migration
- Game Packaging
- Online Services
- DataTable System
- Loot and Crafting System
- CSG System
- Font System
- Timer Manager
- Movie Render Pipeline
- HLOD and World Partition
- Remote Debug System
- Selection Manager
- Asset Dependency Graph
- Editor Automation
- File Watcher
- Project Templates
- System Requirements
- VR Support
- Mobile Platform
- Accessibility
- Platform Input
- Platform Certification
- Cross-Compilation: Wine Testing
- RHI Abstraction Layer
- D3D11 Backend
- D3D12 Backend
- Vulkan Backend
- OpenGL Backend
- Metal Backend
- DXR Raytracing
- Hybrid Ray Tracing
- Upscaling (DLSS/FSR)
- Render Graph
- Shader Graph
- GPU Particles
- GPU-Driven Rendering
- Volumetric Fog
- Volumetric Clouds
- Global Illumination
- Virtual Texturing
- Water Rendering
- Clustered Lighting
- Material System
- Post-Processing
- Shadow System
- Particle System
- Decal System
- Sky and Atmosphere
- Foliage System
- Mesh Shaders
- Neural Rendering
- Configuration Reference
- Performance Tips
- Benchmark Framework
- Threading Model
- Fuzz Policy and Parser Security
- Memory Safety
- Memory Management Patterns
- Build System and CMake Modules
- Profiler and Debugging
- Performance Profiling Guide
- Telemetry System
- Crash Reporting
- Golden Image Testing
- Utilities
- Testing
- Fuzz Policy and Parser Security
- Codebase Statistics
- Codebase Health
- Error Handling Patterns
- Hot Reload Overview
- Troubleshooting
- Contributing
- Workflow Patterns
- Build Optimizations
- CI Reproducible Builds
- CI Controlled-Failure Rehearsal
- Release Publication Stages
- Session Handoff 2026-09-27
- GitHub API and PR Checks
- Git Rebase Conflicts
- Clang-Format
- Code Quality Violations
- AI Bloat Pattern
- MinGW + Wine Cross-Compilation
- Live Editor Testing
- Engine & Renderer Landscape
- DuetOS Portability Catalog
- Five-Engine Analysis
- Eleven-Engine Analysis
- ThorVG / Unity Graphics Analysis
- Advanced Techniques Catalog
- Third-Party Library Evaluation
- Engine Viability Evaluation
- Engine Feature Recommendations
- Project Recommendations
- Mac Compatibility Analysis
- Codebase Observations
- Codebase Bloat Audit
- Test Suite Audit
- Documentation Coverage Audit
- ThirdParty Dependencies Audit
- Load Test Baseline
- Gameplay Systems Status
- SparkGame Module Status
- Stub and Abandoned Features
- Memory Integrity System
- Memory Safety Evaluation
- Hardware Acceleration Systems
- Jolt Physics Integration
- GPU/CPU Separation Plan
- Daemon Services Architecture
- Online Service Boundary
- Server Operations Runbook
- Reflection & Polymorphism Refactoring Plan
- SparkBuild In-Tree
- Wine No-JobSystem Breakthrough
- Wine Role and Fallback Tiers