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...