Fundamentals
There are a few basic concepts that you need to understand to fully grasp the whole network protocol from scratch.
Tokens
Every game related packet (excluding server info for now) has a packet header with a token field. From network.h:
packet header: 7 bytes (9 bytes for connless)
unsigned char flags_ack; // 6bit flags, 2bit ack
unsigned char ack; // 8bit ack
unsigned char numchunks; // 8bit chunks
unsigned char token[4]; // 32bit token
// ffffffaa
// aaaaaaaa
// NNNNNNNN
// TTTTTTTT
// TTTTTTTT
// TTTTTTTT
// TTTTTTTT
packet header (CONNLESS):
unsigned char flag_version; // 6bit flags, 2bits version
unsigned char token[4]; // 32bit token
unsigned char responsetoken[4]; // 32bit response token
// ffffffvv
// TTTTTTTT
// TTTTTTTT
// TTTTTTTT
// TTTTTTTT
// RRRRRRRR
// RRRRRRRR
// RRRRRRRR
// RRRRRRRR
if the token isn't explicitly set by any means, it must be set to
0xffffffff
The token field contains a token sent by the receiving party;
when the client sends a packet to the server, the token field
contains the token previously sent by the server and vice versa.
The client and server each generate a random token and then exchange it.
If the token is not known yet, the token field will be FF FF FF FF indicating it being empty. This happens only in the very first packet the client sends to initiate the connection.
If the client or server gets a packet with an unexpected token it will simply ignore it without any error message in the logs. If you are trying to reimplement the protocol, this is one of the first things you need to get right before you can properly send valid packets.
Quick debug tip if you suspect token bugs in your implementation: comment out or add a
dbg_msg
in
this line
where packets with wrong
tokens are silently dropped.
Sequence numbers
Every chunk header can contain a vital flag
(MSGFLAG_VITAL
). Note that one Teeworlds packet can
contain multiple chunks. If said vital flag is set to true, the
receiving party has to increment a counter. This counter is called
the sequence or acknowledge number. The client and server will
send the amount of vital chunks they received as the sequence
number in the packet header.
The
CNetConnection
class keeps track of these sequence numbers.
unsigned short m_Sequence;
unsigned short m_Ack;
unsigned short m_PeerAck;
Using these three variables, the client and server keep track of:
- m_Sequence - The amount of vital chunks sent
- m_Ack - The amount of vital chunks received
- m_PeerAck - The amount of vital chunks acknowledged by the peer
Quick debug tip if you suspect sequence number bugs in your implementation: comment out or add
dbg_msg
s in
these lines.
Message queueing, chunks and flushing
Control messages are sent immediately. All control messages go through theSendControlMsg()
method which calls SendPacket()
directly.
In SendPacket()
the Teeworlds packet header gets added
and the packet is sent over the network. Note that control messages
always have 0 chunks because of the Construct.m_NumChunks = 0;
line in SendControlMsg()
:
void CNetBase::SendControlMsg(const NETADDR *pAddr, TOKEN Token, int Ack, int ControlMsg, const void *pExtra, int ExtraSize)
{
CNetPacketConstruct Construct;
Construct.m_Token = Token;
Construct.m_Flags = NET_PACKETFLAG_CONTROL;
Construct.m_Ack = Ack;
Construct.m_NumChunks = 0;
Construct.m_DataSize = 1+ExtraSize;
Construct.m_aChunkData[0] = ControlMsg;
if(ExtraSize > 0)
mem_copy(&Construct.m_aChunkData[1], pExtra, ExtraSize);
// send the control message
SendPacket(pAddr, &Construct);
}
However, game messages
and system messages
usually go through SendMsg()
-> m_NetClient.Send(&Packet);
where they end up here:
if(pChunk->m_Flags&NETSENDFLAG_CONNLESS)
{
// [..]
}
else
{
// [..]
m_Connection.QueueChunk(Flags, pChunk->m_DataSize, pChunk->m_pData);
}
As the name suggests, m_Connection.QueueChunk()
only puts
these messages into a queue of so-called chunks; no packet
header is created and no data is being sent over the network yet. For
now the messages only get collected in a queue.
This queue is then sent as soon as it is full or a message with the
flag MSGFLAG_FLUSH
is
sent. When MSGFLAG_FLUSH
is sent, all messages in the
queue get their own chunk header and in the very beginning of the UDP
packet, the packet header includes one field stating the amount of
chunks in the packet.
This is probably done for performance reasons: to avoid wasting bandwidth on Teeworlds/UDP/IP headers when non time critical messages are being sent that are followed by other messages anyways; messages are bundled up and sent together.
If you are calling
SendMsg()
and care about the time
delay, make sure to use the MSGFLAG_FLUSH
flag otherwise
it might be slightly delayed. The delay is not very dramatic since
flushes happen quite frequently so it should only be used for super
time sensitive things such as user inputs or tee positions.
Let's illustrate chunks and flushing via a real world example. When a client connects to a server, the first packet received with multiple chunks is Motd, Server Settings, Ready. This happens when
OnClientConnected()
is called, which is the function
where the MOTD and server settings are sent.
void CGameContext::OnClientConnected(int ClientID, bool Dummy, bool AsSpec)
{
// [..]
// send motd
SendMotd(ClientID);
// send settings
SendSettings(ClientID);
}
void CGameContext::SendMotd(int ClientID)
{
CNetMsg_Sv_Motd Msg;
Msg.m_pMessage = Config()->m_SvMotd;
Server()->SendPackMsg(&Msg, MSGFLAG_VITAL, ClientID);
}
void CGameContext::SendSettings(int ClientID)
{
CNetMsg_Sv_ServerSettings Msg;
Msg.m_KickVote = Config()->m_SvVoteKick;
Msg.m_KickMin = Config()->m_SvVoteKickMin;
Msg.m_SpecVote = Config()->m_SvVoteSpectate;
Msg.m_TeamLock = m_LockTeams != 0;
Msg.m_TeamBalance = Config()->m_SvTeambalanceTime != 0;
Msg.m_PlayerSlots = Config()->m_SvPlayerSlots;
Server()->SendPackMsg(&Msg, MSGFLAG_VITAL, ClientID);
}
Note that the call to SendPackMsg()
contains the
flag MSGFLAG_VITAL
, not MSGFLAG_FLUSH
so
they are just added to the queue rather than being sent right
away. Directly after OnClientConnected()
is
called, SendConnectionReady()
gets called.
GameServer()->OnClientConnected(ClientID, ConnectAsSpec);
SendConnectionReady(ClientID);
void CServer::SendConnectionReady(int ClientID)
{
CMsgPacker Msg(NETMSG_CON_READY, true);
SendMsg(&Msg, MSGFLAG_VITAL|MSGFLAG_FLUSH, ClientID);
}
This time the flag MSGFLAG_FLUSH
is used which causes the
whole queue (currently MOTD, Settings) to be sent, including the newly
added CON_READY. Basically we're queueing 3 messages and sending them
together, but this is all happening in the same tick so it feels
instant.
So these three pseudocode method calls:
Send(CNetMsg_Sv_Motd, MSGFLAG_VITAL); // nothing is sent
Send(CNetMsg_Sv_ServerSettings, MSGFLAG_VITAL); // nothing is sent
Send(NETMSG_CON_READY, MSGFLAG_VITAL|MSGFLAG_FLUSH); // all 3 are sent
cause one UDP packet to be sent that looks like this:
+-------------------------+---------------+------+---------------+-----------------+---------------+-----------+
| teeworlds packet header | chunk header | motd | chunk header | server settings | chunk header | CON_READY |
| chunks = 3 | vital | | vital | | vital & flush | |
+-------------------------+---------------+------+---------------+-----------------+---------------+-----------+
Int packing
The libtw2 docs also has documentation on ints.
There is also a more technical overview with examples about int packing.
All integer numbers sent through the network are packed using a custom Teeworlds-specific packer.
Even though it's a custom packer, the numbers 0-63 are pretty much the standard binary representation;
binary
00000001
is decimal 1
,binary
00000010
is decimal 2
,and so on. If we exceed 63 it starts to differ.
The int packer code can be found here, together with this annotation comment:
Format: ESDDDDDD EDDDDDDD EDD... Extended, Data, Sig
This means that the second bit of the first byte of the integer is
its sign bit: whether or not the number is signed (that
is, negative). If this byte is 1
, the number is
negative. If this byte is 0
, the number is positive.
The first bit of every byte is the extension bit. If it is set to
1
, another byte is following. If it is set
to 0
, the current byte is the last byte. This
extension bit allows you to send small numbers (-62 to +63) as a
single byte over the network. It also means that technically the
protocol allows numbers of any size; however, this is advised
against as they are read into a 4-byte-long C++ int
.
Note that the byte order is little endian. The smallest possible two byte number, 64, is represented as the bits
10000000 00000001
.This number 64 can be dissected as so:
ESDDDDDD EDDDDDDD
10000000 00000001
^^^ ^ ^^ ^
||\ / | \ /
|| \ / not \ /
|| \ extended
|| \ /
|| \ /
|| \ /
|| \/
|| /\
|| / \
|| / \
|| 0000001 000000
|| |
|| v
|| 64
||
|positive
extended
There is a Rust crate named teeint which solely focuses on int packing.
String packing
When strings are sent via the network, they are sent as plain C
strings. They do not contain a length, and they have to be null
terminated.
The receiver can then use different types of sanitizers when
unpacking the string:
- SANITIZE - Calls str_sanitize(char *) which makes sure that the string only contains characters between 32 and 255, plus the control characters \r, \n, and \t
- SANITIZE_CC - Calls str_sanitize_cc(char *) which makes sure that the string only contains characters between 32 and 255
- SKIP_START_WHITESPACES - Calls str_utf8_skip_whitespaces(const char *) on the string to remove leading whitespace.
Raw packing
If you want to send raw bytes over the network, the Teeworlds
protocol got you covered.
There is
void CPacker::AddRaw(const void *pData, int Size)
and its counterpart
const unsigned char *CUnpacker::GetRaw(int Size)
They will just send and receive byte by byte the raw data you give
it. Note that while reading bytes you have to know the size in
advance. The raw data does not include a size field or is
terminated in any way.
Raw data can only be used in contexts where the length is known to
the recipient. This usually happens by a leading
integer field holding the size.
Huffman compression
Packets can be compressed using Huffman compression. The Teeworlds
packet header is never compressed, only its payload. The packet
header sets the COMPRESSION flag if the payload is
compressed. Usually corresponding packets are compressed, but
technically every packet could be compressed or not.
The Huffman compression algorithm is not specific to Teeworlds.
To understand its fundamentals you can simply check out any
non-Teeworlds related Huffman documentation like
this YouTube
video. Huffman compression depends on a weight tree, so if you
reimplement Huffman yourself make sure it uses the same weight
tree as the official Teeworlds implementation does.
The official Teeworlds frequency table can be found
here.
But chances are you do not have to reimplement the Huffman
algorithm even if you want to build a server or client in a new
programming language.
There are already community implementations for
Python,
JavaScript,
Ruby
and
Rust.