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