CelsiusNet

CelsiusNet is a C++ networking library I built after being given an open-ended research assignment at AIE. I wanted to make something I would genuinely use and I was already interested in networking, building my own packet-based UDP layer felt like the obvious choice. : p

2026  |  C++20 LIBRARY  |  COMPLETE

How It Works

1 Your game creates a packet object and places it into the host's outgoing queue.
2 The sending thread then serializes that packet into a CelsiusStream, and sends the resulting data through WinSock
3 The receiving thread reads the header, updates reliability state, rejects duplicate packets, and asks the packet factory to create the matching packet type.
4 The payload is then deserialized and placed into a thread-safe incoming queue.
5 Calling Tick() on the main thread drains that queue and calls each packet's Handle() function.

PACKET -> SERIALIZE -> UDP -> FACTORY -> QUEUE -> HANDLE

Starting a Host

A host can act as either a server or a client. A server binds its UDP socket to the set address, while a client uses that address as its destination. The send and receive loops run on their own std::jthread, while Tick() stays under the application's flow and control!

CelsiusHost host(true, true, this);

host.SetIPv4(L"127.0.0.1");
host.SetHostPort(7777);

if (host.Initialize() == 0) {
    host.DetachReceiver();
    host.DetachSender();
}

while (running) {
    host.Tick();
}

Defining a Packet

Every packet derives from CelsiusPacket. The PACKET_TYPE macro assigns an ID and automatically registers a constructor with CelsiusFactory. This lets the receiver turn the packet ID from the wire back into the correct C++ class later on

class MessagePacket : public CelsiusPacket {
public:
    PACKET_TYPE(MessagePacket, 4);

    std::string message;

    void Write(CelsiusStream& stream) override {
        stream.WriteData<std::string>(message);
    }

    void Read(CelsiusStream& stream) override {
        message = stream.ReadData<std::string>();
    }

    void Handle(void* context) override {
        // React to the packet on the main thread.
    }

    void Reset() override {
        message.clear();
    }

    bool IsReliable() const override {
        return true;
    }
};

Sending the packet is then just a matter of creating the packet and moving it into the outgoing queue

auto packet = std::make_unique<MessagePacket>();
packet->message = "Hello World";
host.QueueOutgoingPacket(std::move(packet));

Reliable UDP

UDP is fast and lightweight, but it does not guarantee that a packet will arrive, arrive only once, or arrive in order. CelsiusNet keeps UDP as the transport and adds an optional reliability layer for packets that need it.

Each datagram carries a sequence number, the newest sequence received from the other peer, and a 32-bit acknowledgement history. Reliable packets are retained in a resend queue and sent again after 150 ms until the remote acknowledgement removes them. Duplicate and overly old sequence numbers are discarded.

Field Size Purpose
Sequence 16 bits Identifies this datagram.
Latest ACK 16 bits Newest sequence received from the remote peer.
ACK mask 32 bits Reports receipt of the previous 32 sequences.
Flags 8 bits Currently only marks reliable packets.
Packet ID 32 bits Selects the packet class through the factory.

This header is 13 bytes, in any given use it's then followed immediately by the serialized packet payload.

Bit Packing

My CelsiusStream can write ordinary trivially-copyable values, strings, or a chosen number of bits from an integral value. This is useful for small game-state fields that do not need an entire byte or integer.

// 3 bits allow values from 0 to 7.
stream.WriteBits<uint8_t>(direction, 3);

// A boolean only needs one bit.
stream.WriteBits<uint8_t>(isSprinting ? 1 : 0, 1);

The stream tracks its current bit position and automatically aligns back to the next byte before reading or writing normal data.

What I Learned

CelsiusNet gave me a much stronger understanding of what sits beneath the networking libraries I normally use. I had to think about binary layouts, serialization, byte and bit alignment, sequence-number wraparound, acknowledgement windows, duplicate detection, and the difference between network-thread work and game-thread work.

It also taught me a lot about API design. The difficult part was not simply sending bytes. It was making the system pleasant enough that I would actually choose to use it in other projects...