Skip to content

Choosing a Transport

irrld edited this page Jul 30, 2026 · 4 revisions

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

ClientConfig config{"127.0.0.1", 25000, std::chrono::seconds(10),
                    ConnectionType::ZDT};

The short answer

Use 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.

What actually differs

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

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

void Send(PeerSession& session, std::shared_ptr<Packet> packet) {
  // default: reliable and ordered, on channel 0
  session.SendPacket(packet);

  // position updates: the newest one supersedes a lost one, so neither
  // retransmitting nor holding the line for it is worth the latency
  SendOptions unreliable;
  unreliable.Set<ReliableKey>(false);
  unreliable.Set<OrderedKey>(false);
  session.SendPacket(packet, unreliable);

  // reliable but order-independent: nothing is lost, and one gap does not
  // hold back everything queued behind it
  SendOptions unordered;
  unordered.Set<OrderedKey>(false);
  session.SendPacket(packet, unordered);

  // a separate channel, so chat is not stuck behind a bulk transfer
  SendOptions chat;
  chat.Set<ChannelKey>(2);
  session.SendPacket(packet, chat);
}

At C++20 or newer the same thing reads better with designated initializers:

session.SendPacket(packet, SendOptions{{.reliable = false, .ordered = false}});

Note the double brace — the inner one builds a SendOptionsInit. This form does not compile at C++14 or C++17; use Set<Key>() there. GCC and Clang will accept it below C++20 as an extension, but not under -pedantic, and MSVC will not.

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.

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.

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