zenet
zenet
Ai assisted — Well state machines and some transport was written by me, other parts were later done by ai with my view on things (I reviewed and tested). Originally was developing just to get a grasp on how architecture works(Though i do not mean that current architecture is correct one).
Experimental — API is unstable and subject to change.
Not an ENet binding. If you are looking for Zig bindings to the ENet C library, see znet.
Connection-oriented game networking library for Zig. Provides a challenge-response handshake (HMAC-SHA256) and a channel system on top of unreliable UDP. Inspired by renet.
Requires Zig 0.16.0
Examples
Two worked examples are included under examples/. They are built from the separate examples package so the root library package stays free of GUI/example dependencies.
Ball
Up to 4 clients move colored balls around a shared window. Demonstrates ReliableOrdered for session events, UnreliableLatest for ball positions, and Unreliable for chat.

zig build --build-file examples/build.zig ball-server # terminal 1
zig build --build-file examples/build.zig ball-client # terminal 2
zig build --build-file examples/build.zig ball-client -- 1.2.3.4
Controls: WASD to move · T to chat · Esc to cancel chat
Pong
Two-player networked pong. Demonstrates UnreliableLatest for ball/paddle state and player input, ReliableOrdered for control events, and Unreliable for chat.
zig build --build-file examples/build.zig pong-server # terminal 1
zig build --build-file examples/build.zig pong-client # terminal 2
zig build --build-file examples/build.zig pong-client # terminal 3
zig build --build-file examples/build.zig pong-client -- 1.2.3.4
Controls: W/S to move · T to chat · Space to ready up after a round
Contents
- Quick start
- Options
- Channels
- TransportServer / TransportClient
- Custom socket
- Secure connections / ConnectToken
- Raw state machines
- Testing with LoopbackSocket
- Project structure
Quick start
Most of the Zig 0.16 migration is internal. From the user side, the main
visible change is that addresses are now zenet.Address (an alias for
std.Io.net.IpAddress), while the transport and channel APIs stay the same.
const zenet = @import("zenet");
const std = @import("std");
const opts: zenet.Options = .{
.max_clients = 64,
.max_payload_size = 512,
.channels = &.{
.{ .kind = .Unreliable },
.{ .kind = .UnreliableLatest },
.{ .kind = .ReliableOrdered },
.{ .kind = .ReliableUnordered },
},
};
// --- server ---
const Srv = zenet.TransportServer(opts, void); // void = built-in UDP
var srv = try Srv.init(allocator, zenet.ServerConfig.init(
1, // protocol_id
5 * std.time.ns_per_s, // handshake_alive_ns
30 * std.time.ns_per_s, // client_timeout_ns
&.{}, // public addresses (secure mode only)
false, // secure
[_]u8{0} ** 32, // challenge key
null, // secret key (secure mode only)
), try zenet.Address.parseIp4("0.0.0.0", 9000));
defer srv.deinit();
// game loop
while (running) {
srv.tick();
while (srv.pollEvent()) |ev| switch (ev) {
.ClientConnected => |e| std.debug.print("connected cid={}\n", .{e.cid}),
.ClientDisconnected => |e| std.debug.print("disconnected cid={}\n", .{e.cid}),
};
// zero-copy: pointer into the ring buffer — no data[] copy
while (srv.peekMessage()) |msg| {
const data = srv.messageData(msg);
std.debug.print("ch={} data={s}\n", .{ msg.channel_id, data });
// echo back
try srv.sendOnChannel(msg.cid, msg.channel_id, data);
srv.consumeMessage();
}
}
// --- client ---
const Cli = zenet.TransportClient(opts, void);
var cli = try Cli.init(
.{ .protocol_id = 1, .server_addr = server_addr },
try zenet.Address.parseIp4("0.0.0.0", 0),
);
defer cli.deinit();
try cli.connect();
while (running) {
cli.tick();
while (cli.pollEvent()) |ev| switch (ev) {
.Connected => std.debug.print("connected\n", .{}),
.Disconnected => std.debug.print("disconnected\n", .{}),
};
// zero-copy: pointer into the ring buffer — no data[] copy
while (cli.peekMessage()) |msg| {
std.debug.print("from server ch={} data={s}\n",
.{ msg.channel_id, cli.messageData(msg) });
cli.consumeMessage();
}
// send on channel 2 (ReliableOrdered)
try cli.sendOnChannel(2, "hello");
}
Options
Both sides must use the same Options value — the wire format depends on it.
const opts: zenet.Options = .{
// Connection limits
.max_clients = 1024, // max simultaneous connected clients
.max_pending_clients = null, // defaults to max_clients * 2; must be power of 2
// Wire format
.max_payload_size = 1024, // bytes per payload (includes channel header)
.user_data_size = 256, // bytes carried in ConnectToken.user_data
// Channels (index = channel_id passed to sendOnChannel)
.channels = &.{
.{ .kind = .Unreliable },
.{ .kind = .UnreliableLatest },
.{ .kind = .ReliableOrdered, .reliable_buffer = 64, .ordered_recv_window = 32 },
.{ .kind = .ReliableUnordered, .reliable_buffer = 64, .unordered_recv_window = 64 },
},
// Global reliable timing
.reliable_resend_ns = 100 * std.time.ns_per_ms, // minimum retransmit interval (floor for RTT-based RTO)
// Queue sizes
.outgoing_queue_size = 256,
.events_queue_size = 256,
.messages_queue_size = 256,
.nonce_window = 256, // replay-protection window
// Optional custom ConnectToken type (void = use built-in default)
.ConnectToken = void,
};
reliable_resend_ns acts as the minimum floor for the per-client adaptive
retransmit timeout. The transport layers track a smoothed RTT (SRTT) per
connection and compute RTO = max(srtt * 2, reliable_resend_ns), so on low
latency links retransmits happen sooner and on high latency links spurious
retransmits are avoided.
Channels
Every message is sent on a numbered channel (index into opts.channels).
Four kinds are available:
| Kind | Delivery | Ordering | Use for |
|---|---|---|---|
Unreliable |
fire-and-forget | none | audio, debug overlays |
UnreliableLatest |
fire-and-forget | drops older | positions, orientations |
ReliableOrdered |
ACK + retransmit | in order | game events, match flow |
ReliableUnordered |
ACK + retransmit | none | idempotent reliable notifications |
Each channel is configured with a ChannelConfig struct:
.channels = &.{
// channel 0 — minimal unreliable
.{ .kind = .Unreliable },
// channel 1 — latest-only positions
.{ .kind = .UnreliableLatest },
// channel 2 — ordered events; deep recv window for jittery links
.{ .kind = .ReliableOrdered, .reliable_buffer = 32, .ordered_recv_window = 16 },
// channel 3 — bulk world state; fragmented so messages can exceed max_payload_size
.{ .kind = .ReliableOrdered, .reliable_buffer = 64, .fragment_size = 512 },
},
ChannelConfig fields (all optional, shown with defaults):
| Field | Applies to | Default | Description |
|---|---|---|---|
kind |
all | — | Channel delivery kind (required) |
reliable_buffer |
ReliableOrdered, ReliableUnordered |
64 |
Unacked send slots per peer |
ordered_recv_window |
ReliableOrdered |
32 |
Future-packet buffer depth; 0 = no buffering |
unordered_recv_window |
ReliableUnordered |
64 |
Dedup sliding-window size |
fragment_size |
all except UnreliableLatest |
null |
Enable fragmentation; see below |
// send and receive
try cli.sendOnChannel(0, "fire-and-forget");
try cli.sendOnChannel(1, &std.mem.toBytes(player_position));
try cli.sendOnChannel(2, "must arrive in order");
try cli.sendOnChannel(3, large_world_state_bytes); // fragmented transparently
while (srv.peekMessage()) |msg| {
switch (msg.channel_id) {
0 => ..., // Unreliable
1 => ..., // UnreliableLatest — older packets already dropped
2 => ..., // ReliableOrdered
3 => ..., // assembled from fragments — messageData() returns full slice
else => {},
}
srv.consumeMessage();
}
sendOnChannel returns error.ReliableBufferFull when all reliable_buffer
slots are occupied by unACKed messages for that peer. Back off and retry on the
next tick.
Fragmentation
Set fragment_size on any channel (except UnreliableLatest) to transparently
split messages larger than a single UDP payload. The constraint is:
FRAG_HEADER_SIZE (9) + fragment_size ≤ max_payload_size
Each fragment is sent as a separate packet with its own sequence number; on
reliable channels each fragment is individually ACKed and retransmitted. The
receiver reassembles them and delivers the complete message to peekMessage /
messageData — the assembled buffer is heap-allocated and freed when you call
consumeMessage.
const opts: zenet.Options = .{
.max_payload_size = 256,
.channels = &.{
// fragment_size=200: each fragment carries 200 bytes of user data
// 200 + 9 = 209 ≤ 256 ✓
// a 600-byte message becomes ceil(600/200) = 3 fragments
.{ .kind = .ReliableOrdered, .reliable_buffer = 64, .fragment_size = 200 },
},
};
// send — any size up to 255 × fragment_size bytes (and ≤ 65535 bytes total)
try srv.sendOnChannel(cid, 0, large_bytes);
// receive — messageData() returns the full assembled slice
while (cli.peekMessage()) |msg| {
const data = cli.messageData(msg); // []const u8, len = original message size
cli.consumeMessage(); // frees the assembled buffer
}
Note:
pollMessage(copy-out convenience) truncates assembled messages tomax_user_databytes. Use the zero-copypeekMessage/messageData/consumeMessageAPI for fragmented channels.
TransportServer / TransportClient
The transport wrappers own a socket and drive the full I/O loop for you.
Pass void as the second type argument to use the built-in UDP socket.
TransportServer
const Srv = zenet.TransportServer(opts, void);
// init
var srv = try Srv.init(allocator, server_config, bind_address);
defer srv.deinit();
// --- per-tick ---
srv.tick(); // recv → state machine → send → retransmit reliable
// lifecycle events
while (srv.pollEvent()) |ev| {
switch (ev) {
.ClientConnected => |e| {
// e.cid : u64 — stable slot index, 0-based
// e.addr : zenet.Address
// e.user_data : ?[opts.user_data_size]u8 (null if plain connect)
},
.ClientDisconnected => |e| {
// e.cid, e.addr
},
}
}
// incoming messages — zero-copy (preferred for large payloads)
while (srv.peekMessage()) |msg| {
// msg : *const MessageView — points into ring buffer, no data copy
// msg.cid : u64
// msg.channel_id : u8
// Use srv.messageData(msg) to read the payload bytes.
_ = srv.messageData(msg);
srv.consumeMessage(); // advance the ring buffer and release pooled bytes
}
// or copy-out variant (simpler, fine for small payloads)
while (srv.pollMessage()) |msg| {
_ = msg.data[0..msg.len]; // msg is a value copy
}
// outgoing
try srv.sendOnChannel(cid, channel_id, data_slice);
// access the underlying state machine (e.g. to send raw payloads)
const sm = srv.getStateMachine(); // *Server(opts)
try sm.sendPayload(cid, payload_body);
TransportClient
const Cli = zenet.TransportClient(opts, void);
var cli = try Cli.init(client_config, bind_address);
defer cli.deinit();
try cli.connect(); // plain
// try cli.connectSecure(token); // with ConnectToken
// --- per-tick ---
cli.tick();
while (cli.pollEvent()) |ev| {
switch (ev) {
.Connected => {},
.Disconnected => {},
}
}
// zero-copy (preferred for large payloads)
while (cli.peekMessage()) |msg| {
// msg : *const MessageView — points into ring buffer, no data copy
// Use cli.messageData(msg) to read the payload bytes.
_ = cli.messageData(msg);
cli.consumeMessage();
}
// or copy-out variant
while (cli.pollMessage()) |msg| {
_ = msg.data[0..msg.len];
}
try cli.sendOnChannel(channel_id, data_slice);
cli.disconnect(); // graceful
ServerConfig
const cfg = zenet.ServerConfig.init(
protocol_id, // u32 — must match client
handshake_alive_ns, // u64 — ns; pending handshake lifetime
client_timeout_ns, // u64 — ns; idle disconnect threshold
public_addresses, // []const zenet.Address (secure mode)
secure, // bool — require signed ConnectToken
challenge_key, // [32]u8
secret_key, // ?[32]u8 (required when secure = true)
);
ClientConfig
const cfg: zenet.ClientConfig = .{
.protocol_id = 1,
.server_addr = server_address,
.connect_timeout_ns = 5 * std.time.ns_per_s, // ns
.timeout_ns = 30 * std.time.ns_per_s,
};
Custom socket
Pass any type as the second argument to TransportServer / TransportClient.
The type must satisfy the following interface exactly — the compiler checks every
parameter type and return type and emits a focused @compileError if anything
is wrong:
const MySocket = struct {
// Called by TransportServer/Client.init to bind the socket.
pub fn open(addr: zenet.Address) !MySocket { ... }
// Called by deinit.
pub fn close(self: *MySocket) void { ... }
// Non-blocking receive. Return null when no datagram is available.
// The returned struct must have exactly these two fields with these types.
pub fn recvfrom(self: *MySocket, buf: []u8) ?struct {
addr: zenet.Address,
len: usize,
} { ... }
// Fire-and-forget send.
pub fn sendto(self: *MySocket, addr: zenet.Address, data: []const u8) void { ... }
};
const Srv = zenet.TransportServer(opts, MySocket);
What the compiler checks (examples of the errors you get when wrong):
Socket missing: pub fn open(zenet.Address) !MySocket
Socket.open parameter must be zenet.Address, got u16
Socket.open error-union payload must be MySocket, got void
Socket.close must return void, got u32
Socket.recvfrom must return an optional (?RecvResult)
Socket.recvfrom result .addr must be zenet.Address
Socket.sendto third parameter must be []const u8, got []u8
When a custom socket is provided, TransportServer and TransportClient also
expose initWithSocket for use-cases where you construct the socket yourself
(e.g. in tests):
var srv = try zenet.TransportServer(opts, MySocket)
.initWithSocket(allocator, server_config, my_socket_value);
var cli = try zenet.TransportClient(opts, MySocket)
.initWithSocket(client_config, my_socket_value);
Secure connections / ConnectToken
When ServerConfig.secure = true, the server requires every ConnectionRequest
to carry a signed token issued by a trusted matchmaking server.
Using the built-in DefaultConnectToken
// matchmaking server — has the secret key
const token = try zenet.handshake.DefaultConnectToken(
opts.user_data_size,
opts.max_token_addresses,
).create(
client_id,
expires_at, // absolute ns timestamp
public_addresses, // []const zenet.Address
user_data, // [opts.user_data_size]u8
&secret_key,
);
// client — receives token over HTTPS/TCP and uses it
try cli.connectSecure(token);
Custom ConnectToken
Set opts.ConnectToken = MyToken and implement the interface below.
The compiler validates the signatures precisely:
const MyToken = struct {
pub const wire_size = 128; // exact byte size on the wire
user_data: [opts.user_data_size]u8, // required field, exact type
pub fn encode(self: *const MyToken, out: *[wire_size]u8) void { ... }
pub fn decode(bytes: *const [wire_size]u8) ?MyToken { ... }
pub fn verify(
self: *const MyToken,
now: u64,
secret_key: *const [32]u8,
) bool { ... }
pub fn authorizeAddress(
self: *const MyToken,
addr: zenet.Address,
) bool { ... }
};
Errors emitted when the interface is wrong:
ConnectToken must have: pub const wire_size: usize
ConnectToken must have: pub fn encode(*const @This(), *[wire_size]u8) void
ConnectToken must have: pub fn decode(*const [wire_size]u8) ?@This()
ConnectToken must have: pub fn authorizeAddress(*const @This(), zenet.Address) bool
Raw state machines
zenet.Server and zenet.Client are socket-agnostic state machines — drive
the socket yourself if you need full control over when packets are sent.
Server
const Srv = zenet.Server(opts);
var srv = try Srv.init(allocator, config);
defer srv.deinit();
// each tick
srv.updateNow();
// feed a received datagram
try srv.handlePacket(source_addr, datagram_bytes);
// drain outgoing — zero-copy peek/consume pattern
while (srv.peekOutgoing()) |out| {
// out : *const Outgoing — points into the ring buffer
// out.addr : zenet.Address
// out.packet : Packet(opts)
udp_send(out.addr, out.packet);
srv.consumeOutgoing();
}
// or copy-out variant
while (srv.pollOutgoing()) |out| {
udp_send(out.addr, out.packet);
}
// drain lifecycle events — zero-copy peek/consume pattern
while (srv.peekEvent()) |ev| {
switch (ev.*) {
.ClientConnected => |e| { _ = e.cid; },
.ClientDisconnected => |e| { _ = e.cid; },
}
srv.consumeEvent();
}
// or copy-out variant
while (srv.pollEvent()) |ev| { ... }
// drain inbound messages — zero-copy peek/consume pattern
while (srv.peekMessage()) |msg| {
// msg : *const RawMessageView — points into ring buffer
// msg.cid : u64
// msg.payload : Pool.Ref — opaque handle, use payloadData() to read
const data = srv.payloadData(msg.payload);
_ = data; // []const u8 — raw payload bytes (includes 3-byte channel header)
srv.releasePayload(msg.payload);
srv.consumeMessage(); // advance ring only, does NOT release
}
// send a raw payload body to a connected client (copies into outgoing ring)
try srv.sendPayload(cid, payload_body); // payload_body: []const u8
// zero-copy send: reserve a slot and write directly (avoids one memcpy)
const out = try srv.reservePayloadSlot(cid);
const body = &out.packet.Payload.body;
// write your channel header + data into body[0..], then set the length:
out.packet.Payload.len = body_len;
Client
const Cli = zenet.Client(opts);
var cli = try Cli.init(.{ .protocol_id = 1, .server_addr = server_addr });
try cli.connect(); // or cli.connectSecure(token)
// each tick
cli.updateNow();
try cli.handlePacket(datagram_bytes);
// drain outgoing — zero-copy peek/consume pattern
while (cli.peekOutgoing()) |out| {
udp_send(out.addr, out.packet);
cli.consumeOutgoing();
}
while (cli.peekEvent()) |ev| {
switch (ev.*) {
.Connected => {},
.Disconnected => {},
}
cli.consumeEvent();
}
// drain inbound messages — zero-copy peek/consume pattern
while (cli.peekMessage()) |msg| {
// msg : *const RawMessageView — points into ring buffer
// msg.payload : Pool.Ref — opaque handle, use payloadData() to read
const data = cli.payloadData(msg.payload);
_ = data; // []const u8 — raw payload bytes (includes 3-byte channel header)
cli.releasePayload(msg.payload);
cli.consumeMessage(); // advance ring only, does NOT release
}
// send a raw payload body (copies into outgoing ring)
try cli.sendPayload(payload_body);
// zero-copy send: reserve a slot and write directly
const out = try cli.reservePayloadSlot();
out.packet.Payload.len = body_len;
cli.disconnect();
Testing with LoopbackSocket
zenet.LoopbackSocket is an in-memory socket backed by two fixed-size queues.
Create a Pair, get the two socket endpoints from it, and pass them to
initWithSocket — no OS network stack involved.
const LoopbackSocket = zenet.LoopbackSocket;
var pair: LoopbackSocket.Pair = .{};
const srv_addr = try zenet.Address.parseIp4("127.0.0.1", 9000);
const cli_addr = try zenet.Address.parseIp4("127.0.0.1", 9001);
var srv = try zenet.TransportServer(opts, LoopbackSocket)
.initWithSocket(allocator, server_config, pair.serverSocket(srv_addr));
defer srv.deinit();
var cli = try zenet.TransportClient(opts, LoopbackSocket)
.initWithSocket(client_config, pair.clientSocket(cli_addr));
defer cli.deinit();
try cli.connect();
// drive the handshake — packets flow through the shared queues
for (0..10) |_| { cli.tick(); srv.tick(); }
const ev = srv.pollEvent().?;
std.debug.assert(ev == .ClientConnected);
The Pair must remain alive on the stack for the lifetime of both transport
objects, because the sockets hold interior pointers into it.
Project structure
src/
root.zig public API re-exports and Options
packet.zig wire-format serialization/deserialization
channel.zig ChannelConfig, ChannelLayout, header encoding,
FragExtra helpers, ReliableState, RttState,
recv state machines
handshake.zig HMAC challenge tokens, DefaultConnectToken,
validateConnectTokenInterface
payload_pool.zig O(1) free-list pool for inbound message bytes
ring_buffer.zig fixed-capacity queue with peek/consume zero-copy API
addr.zig address normalization for hash-map keys
nonce.zig sliding-window replay protection
tests.zig state-machine integration tests + loopback transport tests
server/
server.zig Server state machine (handlePacket, sendPayload,
reservePayloadSlot, peekOutgoing/consumeOutgoing,
peekMessage/consumeMessage/releasePayload/payloadData, …)
config.zig ServerConfig
connection.zig per-client connection state
error.zig ServerError
client/
client.zig Client state machine (handlePacket, sendPayload,
reservePayloadSlot, peekOutgoing/consumeOutgoing,
peekMessage/consumeMessage/releasePayload/payloadData, …)
config.zig ClientConfig
error.zig ClientError
transport/
socket.zig RecvResult type + validateSocketInterface
udp.zig thin built-in UDP adapter selector
loopback.zig in-memory LoopbackSocket + Pair for testing
server.zig TransportServer — owns socket, drives I/O loop,
per-client RTT tracking, fragment reassembly
client.zig TransportClient — owns socket, drives I/O loop,
per-connection RTT tracking, fragment reassembly
validation/
root.zig re-exports all validation helpers
options.zig comptime checks for Options (channel counts, sizes, …)
socket.zig comptime checks for custom socket interface
connect_token.zig comptime checks for custom ConnectToken interface
License
MIT