Source Engine netcode: Diving In

November 14, 2018

Diving into Source Engine network protocol as a client

I’ve seen it asked several times over the internet, spanning several years; requests for explanation about how Source Engine’s multiplayer network protocol works, and noone has been able to provide any meaningful documentation.

Valve’s developer wiki [LINK] is excellent at explaining the benefits and nuances of Sources networking, but not how a client actually goes from knowing a server <ip_address:port> to being connected and able to interact with the game world. To be fair, why should it? Even your standard developer shouldn’t need to worry about exactly HOW the netcode works. The engines source code provides a set of (reasonably) simple macros for extending and implementing new types of data that should be synced across clients and server.

Regardless of the lack of documentation on the subject, the leak a few years back of some 2007 code from Source Engine games was finally able to shed some light on this subject. By some I mean we basically have some rather difficult to decipher C++, consisting of a few thousand lines in a single .cpp, and a couple of headers with definitions of different data types to send and receive.

Disclaimer: This write-up is based on investigations into Counter-Strike: Source’s protocols. Other games may vary wildly, particularly those based on Source 2009 or later.

Brief technology overview

This information is more broadly available, but lets quickly cover it for completeness’ sake. Source Engine communicates primarily over UDP. Packet contents is of a proprietary format, but doesn’t appear to be encrypted.

Anyway, lets crack on and establish first contact with a server.

First connection

As one would hope for any network protocol that would expect multiple back and forths, as well as a degree of trust between each end, we start with a client asking the server about connecting. So before we look at our first packet, we need to consider from the server’s point of view; who is this packet coming from?

Packet types

Source has 2 main formats of data it sends and receives. We’ll refer to these as Connectionless, and Connected packets.

A Connectionless packet can be easily identifed; as the contents will always begin with a 4 bytes like this: FF FF FF FF. Both the client and server will send packets like this before the client has proved their identity with the server. Following that is a single byte that represents the data type. Communication between both parties requires packets to be sent and received in the right order (obviously), but since this is UDP, assuming the neither party receives a packet that would cause them to stop communication (e.g. a Disconnect packet), it doesn’t seem to matter if they receive a load of junk or duplicate packets at this stage.

A Connected packet is a little more complicated, as it contains a kind of packet header before the main data. We’ll look at this header in a bit, as it’s contents won’t make much sense at this point.

First packet

Anyway, the client obviously establishes contact with the server, using a specific Connectionless packet, that is formatted like so:

[4]byte{255, 255, 255, 255}	// This is our Connectionless header
byte('q')					// This is our packet type. 'q' is used for our first connection request
int32(167679079)			// This is our challenge data. The specific value seems to be unimportant
string("0000000000")		// Must be exactly 10 "0"s. Doesn't appear to serve any specific purpose
byte(0)						// We must null terminate the string above. Source always null-terminates strings

The structure above is the entire data we would send over UDP. It is important to note this there is no 1-/4-byte alignment in this packet, so the total data size is 20 bytes.

Now, after we send this packet to our target server, we can expect to receive a response pretty quickly, assuming our request was correctly defined. If the server fails to respond, our request was likely malformed. From what I’ve seen, servers appear to discard junk packets, rather than respond with anything. This is reasonable.

So the server responds with a Connectionless packet. It has a simple structure too, just like this:

[4]byte{255, 255, 255, 255} // Connectionless header
byte('A')					// 'A' is what we want. '9' means we were refused
int32()						// Server issues a challenge value. This helps prove who we are later
int32()						// Server returns our challenge we sent just now

So the server accepted our request, and we can progress to verification of who we are.

Second packet

So at this point we have 2 values we received from the server: our and their challenge values. We will be using these now in our second packet; which has a structure, just like this:

[4]byte{255, 255, 255, 255}	// This is our Connectionless header
byte('k')					// packet type id
int32(0x18)					// is always 0x18, reason unknown
int32(0x03)					// is always 0x03, reason unknown
int32(serverChallenge)		// server challenge received from the previous response
int32(clientChallenge)		// our client challenge. can be obtained from the previous response
string(playerName)			// our desired playername
byte(0)						// null terminate playername
string(test789)				// server password ("test789" appears to be a default value sent if no password specified)
byte(0)						// null terminate password
string("4630212")			// Game version. I don't know how to obtain this currently without packet sniffing
byte(0)						// null terminate game version
int16(242)					// is always 242, reason unknown
uint64(steamID)				// SteamID. You can get this from Steamworks SDK SteamUser()->getSteamID()
[]byte(steamKey)			// An authentication ticket. Obtained from Steamworks SDK SteamUser()->CreateAuthTicket(). This value can be empty under certain circumstances

Whats immediately obvious here it that we are already progressing quickly with our connection. We’re already providing our player information, and client info now. There isn’t much of real interest here, until our last 2 entries. What is happening here is we are attempting to prove that we own the game we are trying to connect to. In order to do so, we must provide our Steam id to the server, along with a 1-use authentication ticket, provided to us by Steam [LINK].

It is the responsibility of the server to verify the authentication ticket is valid. This is as far as our interaction with steam goes. The server verifies our ticket when it receives our packet; and then is responsible for revoking that tickets validity when we leave the server.

Anyways, we send our packet back to the server, and wait for a response. Similar to before, we want a packet with a specific header byte. If we receive something else, we may need to handle it (such as Connection Refused packets).

What we want is something like this:

[4]byte{255, 255, 255, 255} // Connectionless header
byte('B')					// 'B' is for successful connection
[]byte{}					// Various padding. May have meaning, but not required to connect
Future

I plan on updating this post as I progress more through writing an implementation. You can track the progress of my netcode implementation here: https://github.com/Galaco/sourcenet