Replication client
client.replication() is CrowdyCPP's native implementation of the public
Replication API. It owns the socket, the signing,
the verification, and the connection lifecycle; your game sees typed sends
and typed notification callbacks. This page describes behavior — for the
byte-level layouts, use the canonical references:
Wire formats and
HMAC.
Connect and server assignment
connect() performs the flow from
Authenticate and assign:
- Hold an app-scoped token (the 64-octet token doubles as the HMAC key).
- Call
serverWithLeastClientson the Game API. This has a side effect: it installs your UDP session server-side on the assigned replication server. - Wait for session-ready. Session install is asynchronous on the server,
so the client waits (default 1.5 s,
Config::sessionReadyWaitMs) before the state flips fromConnectingtoConnected. Traffic sent before the session is installed is silently dropped — this wait is not optional politeness. - Exchange signed UDP traffic with the returned host and client port
(IPv4 by default;
Config::preferIpv6opts into the IPv6 address).
Connection state (Idle → Connecting → Connected, with Reconnecting /
Failed / Closed) surfaces through a status callback and state().
Sends
All sends are HMAC-SHA256 signed per the HMAC guide,
thread-safe, and never throw — they return a crowdy::Result carrying the
message's sequence number:
sendActorUpdate— spatial actor statesendVoxelUpdate— voxel editssendAudio/sendText— spatial audio and text packetssendVideoFrame— one encoded webcam frame, cut into up to 16 fragments and sent asCLIENT_VIDEO_PACKET_2(143) each;sendVideosends a single pre-built fragment. Needsuse_video_chat; see Webcam videosendClientEvent— typed client events (eventType+ state bytes)sendGenericSpatial— generic spatial payloadssendSingleActorMessage— direct message to one actor (fire-and-forget, no echo; embed sender identity in the payload if the recipient needs it)sendChannelMessage— publish to a channel (membership +send_messagesenforced server-side)sendRangedChannelMessage(channelId, uuid, payload, origin, maxDistance)(0.58.0+) — publish to a channel, delivered only to members with a live actor withinmaxDistancechunks oforiginby straight-line distance, boundary included (CHANNEL_MESSAGE_RANGED_REQUEST, 32; see the channels guide). The origin's app isConfig::appId;maxDistanceis 0 towire::channel_ranged::kMaxDistance(2147483647). Members receive an ordinary channel messagesendChannelAudio(channelId, uuid, payload)(0.60.0+) — send audio to a channel (CHANNEL_AUDIO_REQUEST, 35; replication v0.37.0): every other member receives it inHandlers::channelAudio(orWorldSessionConfig::onChannelAudio), wherever they are. Needs the channel'ssend_voiceanduse_voice_chat; the payload is at most 1,024 bytes, in any format (thecrowdy::mediavoice helpers are optional)sendHeartbeat— idle keep-alive for your own actor (send every ~2 s while idle so presence never lapses). Since replication v0.37.0 idle players on different servers see each other from the first full update after joining, whatever the heartbeats
Payload bytes are copied into a pooled send buffer during the call, so they need not outlive it.
Bundled sends (0.37.0+)
By default the connection packs the messages you send within a short window into
one MESSAGE_BUNDLE datagram — the framing the server has always used for its
notifications, accepted on the uplink by replication server v0.27.0+
(wire formats). Every
member is still a complete, individually signed message; only the datagram boundary
moves.
Config::bundleSends(defaulttrue).falsesends every message as its own datagram, synchronously from the calling thread — the pre-0.37 behaviour, and what to use against a server older than v0.27.0.Config::bundleWindowMs(default1). How long a pending bundle waits for more messages. The net thread (orpump()) flushes on expiry; the sending thread also flushes an expired bundle before appending to it.0flushes on the very next pass with no deliberate wait.- The bundle also leaves when the next message would not fit (1232 bytes / 32
members), on
Connection::flushSends(), at the end ofWorldSession::tick(), before any*AndWaitstarts waiting, and ondisconnect()or reassignment. A lone message is sent unwrapped; a message too large for any bundle goes alone. - With
Config::manualPumpnothing else flushes between yourpump()calls, so callflushSends()at the end of your frame if you pump less often than you want datagrams out. stats().bundlesSentcounts the datagrams that were wrappers (two or more members);datagramsSentcan now be less thanmessagesSent.
Receive path
Inbound datagrams are processed on the network side and dispatched on your game thread:
- Bundle unpacking — a single datagram can carry multiple notifications; the client unpacks them.
- Per-notification HMAC verification — server-signed notifications are
verified in constant time and mismatches are dropped (counted in
stats().hmacFailures;Config::verifyNotificationsdisables this for benchmarking only). - Typed dispatch — a
Handlersstruct with one callback per notification kind (actor update, voxel update, audio, video, text, client/server event, generic spatial, single-actor message, channel message, actorLeft with itsreasonbyte, generic error, status). - Error frames — server-reported failures arrive as generic-error notifications correlated by sequence number to the send that caused them.
Notification payloads are spans into the receive buffer — valid only during the callback. Copy anything you keep.
Threading modes
- Owned net thread (default). The SDK runs one receive/send thread and
hands you parsed notifications through a lock-free ring; you drain it with
poll()from your game thread. Callbacks always fire on the thread that callspoll()— never on the network thread. - Manual pump (
Config::manualPump). The SDK spawns no threads. You callpump()from your own network thread (or your tick) to do the socket work, andpoll()from the game thread to dispatch callbacks. This is the mode engine wrappers use.
Lifecycle: refresh, reconnect, watchdog
The connection manages its own health so gameplay code doesn't have to:
- Proactive token refresh. The app-scoped token expires; the client
refreshes it through the session provider ahead of expiry
(
Config::refreshLeadMs, default 5 minutes) and rotates the HMAC key. - Verified
COMMAND_RECONNECT. A server under load can ask the client to move. The command's HMAC is verified as server-originated, then the client re-assigns viaserverWithLeastClientswithin the grace period. See Operations. - Silent-drop watchdog (opt-in,
Config::watchdogSilenceMs). The protocol's documented failure mode is that auth failures are often silent drops — sends flow out, nothing comes back. If enabled and traffic has been received before, prolonged one-way traffic triggers reassignment. It is off by default because a lone player in an empty area legitimately receives nothing. See Troubleshooting.
Reconnects and drop counters are visible in stats() (datagrams and messages
sent and received, bundles sent, HMAC failures, malformed frames, ring drops,
reconnects).
AndWait correlation
UDP is best-effort and sequence numbers are correlation, not idempotency. For
request/confirm flows CrowdyCPP offers the same convenience CrowdyJS's
*AndWait methods provide:
sendActorUpdateAndWait/sendVoxelUpdateAndWait— wait (pumping and polling internally; other handlers still fire) until the send's self-echo notification arrives, a correlated error frame arrives, or the timeout elapses. An accepted voxel edit always echoes to its sender (replication v0.37.0); an actor update echoes while your actor is in range of its chunk.sendTextAndWait— text (like audio, video and client events) comes back to you only when your actor is near the target chunk, so its echo is not an acknowledgment; only errors correlate. A clean timeout therefore means accepted.waitForSequence— the low-level primitive: wait on any sequence + uuid.- The first awaited send of a session (the first connect and each reassignment;
Connection::sessionEpoch()counts them) is retried once with a fresh sequence when it is refusedUnauthorizedor, for actor and voxel updates, when its echo times out: how a replication server older than v0.42.0 answers a session's first message. A text send's clean timeout is its success and is never retried (CrowdyCPP 0.72.0). From v0.42.0 the server holds that first message until the session is ready, so the retry does not fire. - An actor or voxel update still waiting for its echo when the connection is
reassigned is resent once, with a fresh sequence, as soon as the new session
is connected, rather than at its timeout: its echo went to the session the
connection left. That is the send's one retry; text is never resent
(CrowdyCPP 0.73.0).
WaitOutcome::sessionChangedis set when the wait ended that way and the resend could not be sent.
sendVideoFrame returns the number of fragments sent; like audio, its echo
reaches you only when your actor is near the chunk, so only a correlated
Unauthorized error frame (no use_video_chat) tells you it was refused.
The outcome reports acknowledged, the correlated error code if any, and the
server epoch-millis from the echo.
Errors
The replication layer never throws on the hot path: sends return
Result codes, server failures arrive as correlated error notifications, and
connection changes surface through the status callback. wire::ErrorCode::AppPaused
(33, replication v0.37.0) means the app is paused (no funds, a spend cap, a lapsed
subscription): keep the session, tell the player, and read the reason from the token
mint's runtimeGate (domains::isAppPaused). Voxel edits take any signed 16-bit
position and type; a state over 1,024 bytes (wire::voxel::kMaxStateSize) is refused
before it is sent, and your own accepted edit always comes back once as a notification. GraphQL-layer
operations (assignment, token mint/refresh) throw the structured exception
types shared with the rest of the SDK — branch on error.code() rather than
parsing messages.
Next
Most games should not drive Connection by hand — the
World session layer packages the send loop,
registries, and caches on top of it.
Webcam video
crowdy/media/video_frames.hpp is the header-only fragmenter and reassembler
that the JS SDK mirrors byte for byte (the fragment header is specified in
Wire formats → Video payload):
#include <crowdy/media/video_frames.hpp>
// Sending: one call per encoded frame (JPEG or WebP bytes), ≤ 8 KB is the target.
std::uint16_t frameId = 0;
auto sent = connection.sendVideoFrame(chunk, myUuid, jpegBytes, frameId++,
crowdy::media::VideoCodec::Jpeg);
// sent.value() is the fragment count; a frame over 16 fragments is refused, not partially sent.
// Receiving: one assembler per connection, fed from Handlers::video (or WorldSessionConfig::onVideo).
crowdy::media::VideoFrameAssembler assembler;
handlers.video = [&](const crowdy::replication::SpatialNotification& n) {
if (auto frame = assembler.ingest(n.uuid, n.payload, nowMs())) {
decodeAndDraw(frame->uuid, frame->codec, frame->bytes); // delivered once per frame
}
};
// On Handlers::actorLeft (or the store's onLeave): assembler.forget(uuid) and drop the texture.
Every receiver within distance pays the bytes: keep distance at 0–1, the
rate ≤ 10 fps and frames small, and expose a toggle. use_video_chat is opt-in
on a tier for the same reason.