Skip to content

Choosing a Transport

irrld edited this page Aug 4, 2026 · 4 revisions

ConnectionType::ZDT or ConnectionType::TCP, set in the config and matching on both ends. Nothing above the session changes: same packets, same handlers, same events.

ZDT is the default, so a config that says nothing about the transport gets it. Naming ConnectionType::TCP is what opts out.

void PickTransport() {
  // ZDT, because that is the default
  ClientConfig zdt_config{"127.0.0.1", 25000, std::chrono::seconds(10)};

  // TCP, explicitly
  ClientConfig tcp_config{"127.0.0.1", 25000, std::chrono::seconds(10),
                          ConnectionType::TCP};
}

The short answer

Keep ZDT unless you have a specific reason not to. It is faster under loss, it does not head-of-line block, and it can send unreliably when that is the right choice. TCP is there for environments that only pass TCP, and for peers you do not control. The TCP backend also serves Unix domain sockets (a unix:/path address, POSIX only), where the loss ZDT exists to handle cannot happen; see Configuration Reference.

What actually differs

ZDT TCP
Underneath UDP, reliability in znet Kernel TCP
Delivery Per-message, four combinations Reliable, ordered, always
Head-of-line blocking Only within a channel, and only if ordered Yes, one stream
Channels 256 No
Max message size ~366 KiB at a 1492 MTU (see below) 4,039 bytes
Congestion control znet, delay-based Kernel, loss-based
MTU Probed during the handshake Not your problem
Handshake Cookie exchange, then znet's key exchange Kernel plus znet's key exchange

Head-of-line blocking is the difference that matters most. On TCP a lost segment stalls everything behind it, including messages that had nothing to do with it. On ZDT that stall is confined to one channel, and only when the message asked to be ordered.

Delivery modes

ZDT only. SendOptions carries three options, reliable, ordered and channel, and all three are read by ZDT alone. TCP is a single reliable ordered stream with no channels, so it ignores every one of them. Passing SendOptions to a TCP session is not an error and is not reported anywhere; it simply has no effect. Code that has to work on either transport cannot lean on these for correctness.

The second argument to SendPacket is per message. Reliable and ordered are both on by default, on channel 0.

Define them once, then reuse them

Build the handful of combinations your application actually uses as constants, and pass those to every send. The point is not that constructing a SendOptions is expensive, it is that the constant names the intent at the call site and keeps one delivery policy in one place instead of scattering the same three fields across every caller.

// reliable, ordered, channel 0: what SendPacket uses when you pass nothing
constexpr SendOptions kDefault{};

// position updates: the newest one supersedes a lost one, so neither
// retransmitting nor holding the line for it is worth the latency
constexpr SendOptions kPosition =
    SendOptions().Reliable(false).Ordered(false).Channel(1);

// reliable but order-independent: nothing is lost, and one gap does not hold
// back everything queued behind it
constexpr SendOptions kChunks = SendOptions().Ordered(false).Channel(2);

// a separate channel, so chat is never stuck behind a bulk transfer
constexpr SendOptions kChat = SendOptions().Channel(3);

void Send(PeerSession& session, std::shared_ptr<Packet> packet) {
  session.SendPacket(packet);  // same as kDefault
  session.SendPacket(packet, kPosition);
  session.SendPacket(packet, kChunks);
  session.SendPacket(packet, kChat);
}

Every builder is constexpr and returns a new SendOptions rather than mutating, so these constants are folded at compile time and the spelling is the same at C++14, 17, 20 and 23.

One caveat if you put them in a header: a namespace-scope constexpr is implicitly const, and therefore internal linkage, before C++17. Each translation unit gets its own copy, which for a three-field value is exactly what you want. Only if you take the address does the difference become observable.

Setting an option at runtime

Set<Key>() mutates in place, for the case where the value is not known until the send happens:

SendOptions ForTick(bool is_keyframe) {
  SendOptions options;
  options.Set<ReliableKey>(is_keyframe);
  options.Set<ChannelKey>(1);
  return options;
}

At C++20 or newer a third spelling sets all three at once with designated initializers:

constexpr SendOptions kUnreliable{{.reliable = false, .ordered = false}};

Note the double brace: the inner one builds a SendOptionsInit. The fields must appear in declaration order, reliable, ordered, channel, because that is what designated initializers require in C++. This form does not compile at C++14 or C++17; GCC and Clang accept it below C++20 as an extension, but not under -pedantic, and MSVC will not. The builder above has none of these restrictions, which is why it is the recommended spelling.

Set is not the same as default

An option you never touched is not the same as one you set to its default value. The transport fills in anything unset with its own default, so kChat above leaves reliability and ordering to ZDT rather than pinning them. For ZDT the two coincide today, which is why kDefault and a fully unset SendOptions behave identically.

What each combination means

Reliable Ordered Behavior Use for
yes yes Retransmitted, delivered in send order Chat, commands, state that must not be lost or reordered
yes no Retransmitted, delivered on arrival Bulk transfers, independent events; one loss stalls nothing
no yes Not retransmitted, stale ones dropped Position and animation, where only the newest matters
no no Not retransmitted, delivered on arrival Voice, telemetry, anything self-contained

"Ordered" without "reliable" means sequenced: a message older than one already delivered is dropped rather than held for its turn, since waiting for something that will never be retransmitted would stall the channel forever.

Channels

256 of them, independent sequence spaces, allocated lazily so idle ones cost nothing. Reliable and unreliable traffic on one channel do not interfere.

Channels exist to keep unrelated traffic from blocking each other. A file transfer on channel 1 and chat on channel 2 will not stall one another even when both are reliable and ordered. Splitting traffic that is already unordered buys nothing.

The separation holds on the send side too: each channel queues on its own lane, and the transport serves the lanes round-robin. A bulk transfer that has filled the congestion window delays its own channel, not the message you queued after it on another one.

Channels are a ZDT concept. On TCP there is one stream and ChannelKey is ignored, so traffic you separated by channel shares a single ordered pipe again.

An encrypted session follows the split: nonce sequences and replay windows are per channel, so a stalled channel is never aged out by a busy one. See Encryption and Compression.

Message size limits

Both transports cap a single message, and both refuse rather than truncate.

TCP frames inside a fixed buffer of ZNET_MAX_BUFFER_SIZE (4096) bytes, so a message must fit in that minus the frame header: 4,039 bytes. Send() logs an error and returns false above it. This is why the benchmark tables show TCP as unsupported at an 8 KiB payload.

ZDT fragments, so the limit is 255 fragments of MTU - 22 bytes each:

MTU Per fragment Largest message
1492 1470 B ~366 KiB
1200 1178 B ~293 KiB
576 554 B ~138 KiB

Above that the message is dropped with an error naming the size and the fragment count it would have needed. Since the MTU is whatever the handshake settled on, a message near the ceiling can succeed on one connection and fail on another. Keep large payloads well under it, or chunk them yourself.

max_reassembly_bytes (16 MiB) is a separate, connection-wide bound on partially reassembled messages held at once, not a per-message limit.

What ZDT does not do

  • No streams. It carries messages. A message is delivered whole or not at all, and there is no byte-stream interface.
  • No unencrypted-by-default assumption. Encryption is on unless turned off, same as TCP. See Encryption and Compression.
  • No NAT traversal on its own. That is Peer-to-Peer.

Tuning

Defaults are meant to be usable as-is. The two worth knowing about:

  • max_datagrams_in_flight (512) caps the congestion window. It counts datagrams, not bytes, so a half-full datagram costs as much as a full one. On a long fat link this is what bounds throughput.
  • rto_min (100 ms) floors the retransmit timeout. On a LAN, lowering it recovers from loss faster; on the open internet, lowering it causes retransmits of packets that were merely late.

Both are ZDT settings and do nothing on a TCP session. Everything else is in Configuration Reference.

Measured behavior

The benchmarks in the README compare both transports against ENet, Valve's GameNetworkingSockets and RakNet, on a clean loopback and over a link with 5% loss and a 50 ms round trip. Read the footnotes: several rows have enough spread that a single measurement from any of these libraries is not a number.

Clone this wiki locally