-
Notifications
You must be signed in to change notification settings - Fork 4
Choosing a Transport
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};
}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.
| 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.
ZDT only.
SendOptionscarries three options,reliable,orderedandchannel, 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. PassingSendOptionsto 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.
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.
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.
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.
| 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.
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.
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.
- 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.
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.
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.