ObzLib networking

obz::transport

Small RAII wrappers for moving bytes between IPv4 TCP and UDP endpoints.

Packets, Not A Networking Framework

The interesting thing about transport is what it does not try to become. It does not define messages, replication, sessions, matchmaking, or an event loop. It gives the program a small way to move bytes between IPv4 endpoints.

That makes it a good partner for small binary packets. A game prototype might build a compact packet elsewhere, then use UDP to send it to a local server, editor tool, or test harness.

obz::transport::udp_socket receiver;
receiver.bind({"127.0.0.1", 0});

obz::transport::udp_socket sender;
sender.open();

std::array<std::byte, 4> payload{
    std::byte{1},
    std::byte{2},
    std::byte{3},
    std::byte{4},
};

sender.send_to(receiver.local_endpoint(), payload);

const auto datagram = receiver.receive_from(1024);

Two Different Ways To Move Bytes

UDP and TCP are kept as different types because they behave differently. UDP sends one datagram to one endpoint. The received value includes both the sender and the payload, which is exactly the shape of a small one-off packet.

struct datagram {
    endpoint sender;
    std::vector<std::byte> payload;
};

TCP is stream-oriented. It has a listener that accepts connections, and a socket that sends and receives bytes once that connection exists.

obz::transport::tcp_listener listener;
listener.listen({"127.0.0.1", 9000});

auto client = listener.accept();
client.send_all(response_bytes);

The Handle Has One Owner

Native sockets are operating-system resources, so ownership matters. The transport types are move-only: one object owns one native socket handle, and that handle is closed automatically when the object is destroyed.

tcp_socket(const tcp_socket&) = delete;
tcp_socket& operator=(const tcp_socket&) = delete;

tcp_socket(tcp_socket&& other) noexcept;
tcp_socket& operator=(tcp_socket&& other) noexcept;

~tcp_socket();

The clean-code point is the boundary. Game code, tools, or tests can talk in terms of endpoints, byte spans, and datagrams, while the platform-specific file descriptors, Winsock handles, close calls, and error conversion stay behind the wrapper.

struct endpoint {
    std::string host;
    std::uint16_t port;
};

One API, Two Platform Backends

The other neat part of the design is the platform split. The public classes do not expose POSIX calls or Winsock calls. They call a small internal socket layer instead.

native_socket_handle create_tcp_socket();
native_socket_handle create_udp_socket();

void connect_socket(native_socket_handle handle, const endpoint& remote_endpoint);
void bind_socket(native_socket_handle handle, const endpoint& local_endpoint);
native_socket_handle accept_socket(native_socket_handle handle);

std::size_t send_tcp(native_socket_handle handle, std::span<const std::byte> data);
std::vector<std::byte> receive_tcp(native_socket_handle handle, std::size_t max_bytes);

The shared code owns the shape of the library. The platform files own the awkward details: address conversion, error codes, close semantics, and Winsock startup on Windows.

if (WIN32)
    target_sources(obz_transport
        PRIVATE
            src/platform/win32_socket_platform.cpp
    )

    target_link_libraries(obz_transport
        PRIVATE
            ws2_32
    )
else()
    target_sources(obz_transport
        PRIVATE
            src/platform/posix_socket_platform.cpp
    )
endif()

That is a useful pattern in a small library: keep the user-facing API stable, then let the build choose the implementation that matches the operating system.

More Libraries