Skip to content

Getting Started

irrld edited this page Sep 4, 2026 · 5 revisions

Adding znet to your project

As a submodule:

git submodule add https://github.com/teoncreative/znet.git external/znet
git submodule update --init --recursive

Then, in your CMakeLists.txt, using the bundled zstd:

add_subdirectory(external/znet/vendor/zstd/build/cmake ${CMAKE_CURRENT_BINARY_DIR}/zstd)
add_subdirectory(external/znet/znet ${CMAKE_CURRENT_BINARY_DIR}/znet)
target_link_libraries(your_target PRIVATE znet)

Or with a system zstd from vcpkg, brew or your package manager:

set(ZNET_USE_EXTERNAL_ZSTD ON)
add_subdirectory(external/znet/znet)
target_link_libraries(your_target PRIVATE znet)

Build options

Option Default Effect
ZNET_CXX_STANDARD 20 14, 17, 20 or 23
ZNET_USE_EXTERNAL_ZSTD OFF Use a zstd you provide instead of the bundled one
ZNET_ENABLE_METRICS ON OFF compiles the counters out entirely
ZNET_ENABLE_LTO ON Link-time optimization for release builds, silently skipped where unsupported
ZNET_MAX_READ_ELEMENTS 65536 Largest element count a vector, map or array read accepts. 0 removes the ceiling
ZNET_MAX_READ_STRING_LENGTH 65536 Longest string a read accepts, in bytes. 0 removes the ceiling
ZNET_ENABLE_STRICT_WARNINGS ON znet's own warning set for its own sources, -Werror included. Nothing reaches your targets
ZNET_PREFER_IPV4 OFF Take the first IPv4 result when a hostname resolves to both families
ZNET_PREFER_STD_SLEEP OFF std::this_thread::sleep_for between ticks instead of the precise spin-and-sleep: less CPU, coarser timing
ZNET_BUILD_EXTENSIONS ON The optional extensions. Each skips itself when its dependency is missing
ZNET_EXT_ALLOW_FETCH ON OFF stops extensions downloading a missing dependency; they skip instead
ZNET_EXT_<NAME> ON One extension off, e.g. -DZNET_EXT_BULLET=OFF. Names are in the extensions page
ZNET_BUILD_BENCHMARKS OFF The benchmark suite. Slow; each comparison library is a further opt-in (ZNET_BENCH_ENET, ZNET_BENCH_RAKNET, ZNET_BENCH_GNS) and is fetched when turned on

ZNET_BUILD_EXTENSIONS and ZNET_BUILD_BENCHMARKS are declared by the top-level CMakeLists.txt, which also pulls in the examples and the tests, so they do nothing for a consumer adding external/znet/znet. To get the extensions that way, add their directory yourself after the library:

add_subdirectory(external/znet/extensions ${CMAKE_CURRENT_BINARY_DIR}/znet-ext)
target_link_libraries(your_target PRIVATE znet-reflect)

A server

Three things happen for every connection: give the session a codec so it can read and write your packets, give it a handler so something receives them, and send.

enum : PacketId { kChatMessage = 1 };

class ChatMessage : public Packet {
 public:
  ChatMessage() : Packet(kChatMessage) {}
  std::string text;
};

class ChatSerializer : public PacketSerializer<ChatMessage> {
 public:
  std::shared_ptr<Buffer> SerializeTyped(std::shared_ptr<ChatMessage> packet,
                                         std::shared_ptr<Buffer> buffer) override {
    buffer->WriteString(packet->text);
    return buffer;
  }
  std::shared_ptr<ChatMessage> DeserializeTyped(std::shared_ptr<Buffer> buffer) override {
    auto packet = std::make_shared<ChatMessage>();
    packet->text = buffer->ReadString();
    return packet;
  }
};

class ChatHandler : public PacketHandler<ChatHandler, ChatMessage> {
 public:
  explicit ChatHandler(std::shared_ptr<PeerSession> session)
      : session_(std::move(session)) {}

  void OnPacket(std::shared_ptr<ChatMessage> packet) {
    auto reply = std::make_shared<ChatMessage>();
    reply->text = "echo: " + packet->text;
    session_->SendPacket(reply);
  }

 private:
  std::shared_ptr<PeerSession> session_;
};

// one codec for every session: serializers are stateless and shared
std::shared_ptr<Codec> g_codec;

bool OnClientConnected(IncomingClientConnectedEvent& event) {
  event.session()->SetCodec(g_codec);
  event.session()->SetHandler(std::make_shared<ChatHandler>(event.session()));
  return false;  // false lets other handlers see the event too
}

void OnEvent(Event& event) {
  EventDispatcher dispatcher{event};
  dispatcher.Dispatch<IncomingClientConnectedEvent>(
      ZNET_BIND_GLOBAL_FN(OnClientConnected));
}

int RunServer() {
  if (znet::Init() != Result::Success) {  // once, before anything else
    return 1;
  }

  g_codec = std::make_shared<Codec>();
  g_codec->Add(kChatMessage, std::make_unique<ChatSerializer>());

  ServerConfig config{"0.0.0.0", 25000};
  Server server{config};
  server.SetEventCallback(ZNET_BIND_GLOBAL_FN(OnEvent));

  if (server.Bind() != Result::Success) {
    return 1;
  }
  server.Listen();  // returns immediately; the server runs on its own thread
  server.Wait();    // blocks until it stops

  znet::Cleanup();  // after the last znet object is gone
  return 0;
}

znet::Init() does the global setup, WSAStartup among it, and every later call is a cheap no-op, so a component may call it defensively. Cleanup() releases what it took.

Listen() returns as soon as the listener is up. Wait() is what blocks, so a program that has other work to do simply does not call it.

A client

Same shape. The difference is that a client has one session, handed to you when the connection completes rather than on accept.

class ClientHandler : public PacketHandler<ClientHandler, ChatMessage> {
 public:
  void OnPacket(std::shared_ptr<ChatMessage> packet) {
    ZNET_LOG_INFO("server said: {}", packet->text);
  }
};

bool OnConnected(ClientConnectedToServerEvent& event) {
  auto codec = std::make_shared<Codec>();
  codec->Add(kChatMessage, std::make_unique<ChatSerializer>());
  event.session()->SetCodec(codec);
  event.session()->SetHandler(std::make_shared<ClientHandler>());

  auto hello = std::make_shared<ChatMessage>();
  hello->text = "hello";
  event.session()->SendPacket(hello);
  return false;
}

void OnEvent(Event& event) {
  EventDispatcher dispatcher{event};
  dispatcher.Dispatch<ClientConnectedToServerEvent>(ZNET_BIND_GLOBAL_FN(OnConnected));
}

int RunClient() {
  if (znet::Init() != Result::Success) {
    return 1;
  }

  ClientConfig config{"127.0.0.1", 25000, std::chrono::seconds(10)};
  Client client{config};
  client.SetEventCallback(ZNET_BIND_GLOBAL_FN(OnEvent));
  if (client.Bind() != Result::Success) {
    return 1;
  }
  client.Connect();  // returns immediately
  client.Wait();

  znet::Cleanup();
  return 0;
}

The third ClientConfig field is the connection timeout. Zero disables it, which means a client dialing an address that never answers waits forever.

SendPacket() only queues, and it returns a Result rather than void. QueueFull is the backpressure signal: nothing was encoded, the packet is still yours, and retrying is the right response. NotReady means the handshake has not settled yet, which is why both snippets above send from the connected event rather than straight after Connect().

Which transport those used

Neither config above named a transport, so both got the default: ZDT, znet's reliable-UDP transport. One field switches to TCP, and nothing above it changes:

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

The server needs the same, and both ends must agree. What each one buys, and what it costs, is in Choosing a Transport. The short version: stay on ZDT unless something in your environment only passes TCP.

Where to look next

Full programs live in the examples folder: basic for the smallest server and client pair, zdt for the delivery modes, userptr for attaching your own state to a connection, multiversion for negotiating packet versions between builds, auth for service-signed tokens and a session-bound client signature, chat-tui for a chat room with fan-out, and p2p for gathering, hole punching and the relay fallback through a rendezvous server. All of them run on ZDT, p2p included once its punch lands; only its hop to the rendezvous server is TCP.

Clone this wiki locally