Free YouTube Transcribe

Video transcript

Why 1999 Quake 3 Netcode Belongs in Every CS Degree

Tariq10x · 7,987 words · 37 min read

Want to search this transcript, jump the video from any line, or download it as TXT, SRT, or VTT?

Open in the transcript tool

Full transcript

Quake 3

0:00It's the late '9s with titles like Doom

0:02and Quake. Its software has changed

0:04gaming forever. Online multiplayer

0:07firsterson shooters are becoming more

0:09and more popular. It software has a

0:11radical idea. What if we dropped the

0:14singleplayer campaign entirely? What if

0:17the next game was only an arena shooter?

0:20But to justify their decision, the

0:22multiplayer had to be flawless. It had

0:25to run on low-spec machines and dial up

0:28connection while at the same time

0:29pushing graphics, physics, and gameplay

0:32to new levels. They wanted to create a

0:34fast-paced arena shooter that felt

0:36smooth on the average PC. So, they had

0:40to beat the limitations of bandwidth,

0:42latency, and relability. Instead of

0:44waiting for the technology to improve,

0:46they decided to challenge it, pushing

0:48the limits of what's possible. What they

0:51built became the foundation of modern

0:54multiplayer networking, pioneering ideas

0:56and concepts that are still used today.

0:59It's not about frameworks or third-party

1:02libraries. It's the evolution of the

1:04engine architecture they had been

1:06shaping since Doom, refined game after

1:09game. And that's why every computer

1:11science student or even experienced

1:14developer should study it. It's a master

1:17class in fundamentals. In my opinion, it

1:20deserves a place in every CS curriculum.

1:23In this video, I'm going to talk about

1:25the snapshot system, delta compression,

1:28Huffman compression, client side

1:30prediction, and why they use UDP instead

1:32of TCP. This is the Quake 3 net code.

Problems

1:38Okay, so first let's start with the

1:40problems. This sounds like a description

1:43of a dinner with my family.

1:46Anyways, at that time it was facing what

1:48I call the impossible triangle.

1:50Bandwidth, latency, and reliability.

1:53Bandwidth was low. Keep in mind, most

1:56people were still on dialup. If you're

1:57younger, you probably never heard that

2:00dialup modem. It made this kind of alien

2:03sound and somehow we all just accepted

2:06it as normal. But if you think about it,

2:08nothing ever since has sounded like

2:10that. And also, your phone didn't work.

2:13Okay, so bandwidth was limited. So they

2:16couldn't just push every little game

2:18change across the connection. Then the

2:20latency was high. If the client waited

2:23for the server to confirm every move,

2:26the game would just feel unresponsive

2:28and then there was reliability. You had

2:30to accept the fact that packets were

2:33going to get lost. That's just how

2:35networks operate. And of course for the

2:38guy having a full crash out. Yes sweetie

2:41you could use raw dog TCP to guarantee

2:44delivery. But TCP comes with a lot of

2:47overhead. It resends all packets even if

2:50the information is no longer relevant.

2:52And in a shooter if something happened 5

2:55seconds ago there's no point in

2:58retransmitting it most of the time. This

3:01of course doesn't include all messages

3:03everything. But most gameplay packets

3:06are not worth resending. So they decided

3:09to go with UDP. I'm going to talk about

3:11this in detail. Okay. Now, the most

3:14impactful highlevel decision in the game

3:17design was this. Quake 3 had no

3:20singleplayer campaign. So the

3:22multiplayer had to carry the whole game.

3:24It had to work. And you know why?

3:28because it wasn't 2025 where everyone

3:31ships half assass finished games for the

3:33full price for like $80 and then you got

3:36to buy the DLC and then the premium pack

3:39to have two more missions. I think we're

3:41just collectively accepting too much and

3:45gradually lowering our standards. But

3:47how do you solve something like this

3:49triangle here? The answer is you don't

3:52solve it with one big magic trick. In

3:55fact, it is very rare that you solve all

3:58bottlenecks with one decision. Usually,

4:01you come up with a lot of small ideas,

4:04each one covering one limitation. And

4:06then when you stack them all together,

4:08you can push through this plateau. Now,

Client Server Architecture

4:12to easier understand how they put all of

4:14this together, let's start with the

4:16Quake 3 client server architecture and

4:18the snapshot system on a very high

4:20level. Okay, so if you watched my last

4:23Quake video, you know that the game is

4:25split into two parts. The client and the

4:28server. The client renders the game,

4:30does bunch of other stuff, handles your

4:32input, and then sends that input to the

4:35server. Now, the server holds the

4:38authoritative game state. It holds the

4:41true state of the game. Since after all,

4:44all the players want to play the same

4:46session, the same game. So, the server

4:48receives all inputs from all clients,

4:52updates the game state, and sends the

4:54updated game state to all the clients.

4:56That's the model. Pretty simple on

4:59paper, but making it actually work for a

5:02fast-paced FPS is a whole other story.

5:06Stay with me. Now, under the hood, the

5:08server runs a snapshot system. In

5:11regular time intervals, the server

5:14creates a snapshot of the current game

5:16state containing all new updates from

5:18the clients. Think of a snapshot like as

5:21a photograph of the whole game state at

5:24a specific moment in time. It contains

5:26all the relevant information. Now they

5:29can just take the snapshot and send it

5:32to all clients, right? Yeah, they could

5:34if there wasn't our triangle of

5:36limitations, bandwidth, latency, and

5:38reliability. So, let's address the most

5:41important concepts that made this work.

5:44Okay, if you're dealing with low

5:46bandwidth, your only option is to send

5:49less data. So, first ask yourself, do I

5:53really need to send all of this data all

5:55the time? That's also something you

5:57should ask yourself when sending the

6:0011th reel today to someone who hasn't

6:03even reacted to the previous 36. Anyway,

6:06ask yourself, do I need to send the

6:08whole snapshot? I mean you can but in

6:12reality not everything is changing all

6:14the time. So why not just send the delta

6:18the difference between the last two

6:20states. So in case nothing has changed

6:24for an entity you just send one bit that

6:27indicates nothing has changed for this

6:29entity. That's delta compression. But

6:33how we going to tackle the problem if

6:34one client gets out of sync and the

6:38other one is still in sync? Not to be

6:40confused with an sync. You can't send

6:43the same delta to both since you would

6:46lose information. So for each client,

6:49the server keeps a snapshot buffer that

6:52holds the last 32 snapshots for that

6:56client, a history. So the server creates

6:58a delta for each client based upon the

7:01last snapshot that has been acknowledged

7:03by the client in case you're wondering.

7:06But isn't that a lot more processing on

7:09the server side than sending out

7:10everything all the time? Yes, and this

7:13is a tradeoff. But our bottleneck isn't

7:16the server side processing power. The

7:19bottleneck is the bandwidth. But let's

7:21continue. So when you reduce the full

7:23snapshot to just the delta you have

7:27scoped out information that has to be

7:29communicated to the clients. Now what

7:32you can do is you can try to represent

7:36the data the same data but with fewer

7:40bits and that's where the Huffman

7:42compression comes in. Don't worry I'm

7:44going to talk about this in detail

7:46during the video. Okay. And then we have

7:48latency. When talking about latency, you

7:51can of course wait every time for the

7:53server to approve acknowledge your

7:55action. But what you can also do is act

7:59now and reconcile later in case your

8:02action is not valid. That is client side

8:05prediction and it makes the game feel

8:07more responsive or super responsive

8:10because you're not waiting for the other

8:12side to acknowledge your actions. And

8:14for the reliability, Quake 3 chose UDP

8:18over TCP. UDP is lightweight and fast,

8:21and if a package gets lost, the game

8:24just keeps going. I mean, it's better to

8:27miss a frame, then freeze for 2 seconds

8:30while waiting for all data. For specific

8:32actions, they have built-in reliability

8:35into the UDP frame. For example, for

8:37actions like a client leaving the game

8:40or changing the map. Now if you put all

8:43of that together, snapshots, deltas,

8:46compression, prediction, UDP, you get

8:50the core of the Quake 3 net code. Not

8:53one giant breakthrough, even though many

8:56of these ideas are genius in my opinion,

8:58but as you can see, it's a bunch of

9:00smart decisions working in harmony. Now,

9:04I'm going to go through each of these

9:06concepts in more detail. So, first we're

9:08going to talk about the techniques that

9:10minimize the needed bandwidth. Since

Bandwidth Challenge

9:12Quake is a FPS game and not chess, you

9:16can see everything all the time, which

9:19when you think about it, is actually

9:21great news for your bandwidth. So, the

9:23first thing you do is ask yourself, why

9:26would you even try to send data about

9:28the stuff the player can't even see? And

9:31this is where the potentially visible

9:34set or PVS comes in. When the map gets

PVS

9:37compiled, they do some pre-processing

9:39and figure out from every possible

9:42position in the map what areas you could

9:45potentially see. So, if your map looks

9:48like this, you get information like if

9:51you're in sector A, you can potentially

9:54see sectors B and C, depending on your

9:57orientation, of course, but you will

9:59definitely not be able to see D, E, and

10:03F. All of this information gets cooked

10:06into the map. So at runtime, we don't

10:08have to do any expensive line of sight

10:11calculations. Let's take a look at the

10:14server snapshot code. So we go in code

10:18server server snapshot C. We have this

10:22function called server add entities

10:25visible from point. So it basically

10:28loops through all the entities in the

10:30world.

10:32But before adding the entity to the PVS

10:36pool, it starts by doing all these let's

10:39call them knockout criteria checks. So

10:44for example, if an entity is not active,

10:46there is no point in checking where it

10:48is.

10:49It doesn't make sense doing first a PVS

10:52calculation and then checking if it's

10:55active. And this is how you should

10:59design your code. I know it sounds like

11:02common sense, but it isn't that common

11:05sense in practice. So, the goal is to

11:08filter out everything before you go into

11:10expensive calculations

11:13or even if the calculation per se isn't

11:17expensive, why would you do it if you

11:19can't just drop it entirely? Okay, so it

11:23starts by defining a bunch of stuff. And

11:26this here is the main loop.

11:30So it's basically iterating through

11:32every single entity starting at zero and

11:35then going all the way up and then we

11:38have this cascade of knockout criteria

11:42filtering or whatever you want to call

11:45it which I mentioned.

11:47So all of these are just bit checks or

11:51integer comparisons. So it's very cheap.

11:54And here we have the PVS check or PVS

11:58lookup. This is a chain of bitfise

12:01operations that basically checks if the

12:04entity's cluster is visible from the

12:06client's position. Now, what is a

12:08cluster?

12:10A cluster is a group of nearby map areas

12:14that tend to see the same stuff. So

12:17instead of tracking a tracking thousands

12:20of thousands of tiny small individual

12:23spaces, Quake tree bundles them

12:26together.

12:27So the whole system is way more

12:30efficient by doing it this way. I mean I

12:33could make an own video about u only

12:36talking about clusters and PVS but let's

12:39continue. Okay. So this client PVS is

12:43the premputed bit array where each bit

12:46represents whether a specific cluster

12:48can be seen. So this loop is just asking

12:51can I see any of the clusters this

12:54entity belongs to. And as soon as it

12:57finds one one visible cluster it breaks

12:59out and it adds the entity. This isn't

13:02doing complex 3D rate tracing or

13:05anything crazy. It's just looking up in

13:07that precomputed table that I mentioned.

13:09So by doing it this way the question can

13:12the client in area A see an entity in

13:17cluster D becomes just is the bit for

13:21cluster D set in the premputed

13:24visibility array and boom table lookup

13:27done. Okay so this cluster nums lets you

13:31draw the conclusion that entities can

13:33span across multiple clusters. So the

13:37loop just checks if any cluster the

13:39entity touches is visible. And as soon

13:42as it finds one one visible cluster, it

13:45breaks out and adds the entity. And at

13:49the end of this function, we have this

13:51really cool portal feature. So if the

13:55entity is a portal, it recursively calls

13:58the same function from the portal's

14:01destination, adding basically everything

14:03visible through the portal. So for

14:06example, mirrors or teleporters

14:09automatically show what's on the other

14:11side. Okay. So after this PVS step,

14:15instead of having to deal with hundreds

14:17of entities, you might only have 20 that

14:20the client actually needs to know about

14:23and that's saving enormous amounts of

14:26bandwidth. Okay, that's that about the

Delta Compression

14:29potentially visible set. Now let's look

14:32at the delta compression because the

14:34code is surprisingly elegant once you

14:36understand what's happening. So let's

14:39first take a look at the entity state

14:41strct which is located in code game

14:45shared header file. The entity state

14:48struck holds data basically for

14:52everything. And I'm talking about all

14:54the data for all the objects in the game

14:57for the players, rockets, items,

15:00everything. Now the classic

15:03objectoriented

15:04programming class way to handle this

15:06would be to write custom code for each

15:10field like just bomb up everything with

15:14gather and setter functions and then

15:17build in a chain of impossible

15:20inheritance. I don't know why but these

15:23university classes love inheritance more

15:26than I don't know frat boys. So you

15:30would end up like having a gazillion

15:33functions to get and set data and then

15:35do that for each entity type something

15:39like if position changed send position.

15:42If health changed send health and you

15:45you would end up with such an enormous

15:48amount of code that's uh also very

15:53inefficient.

15:54And it's also inefficient in a

15:56maintenance sense because imagine you

15:58want to add something and then you also

16:01need to add code to compare it to encode

16:04it decode it and all that stuff. It's

16:07very errorprone but the it guys did

16:10something way more elegant. So let's

16:13first start with the net field and this

16:15will all make sense when we come to the

16:17right delta entity function if you're

16:20not a prompt engineer of course. So

16:23let's open message C. And here we can

16:26find the net field strct. This is the

16:29strct for each entry in the lookup table

16:32that drives this whole delta system.

16:34It's got the field name, the bite offset

16:37within the strct and the number of bits

16:40to use for network encoding. And then

16:43comes a little bit of pre-processor

16:45magic that makes the table easy to

16:47maintain. But don't get scared. I know

16:49when you first time see something like

16:51this, it looks like

16:54do I really need a job?

16:59But let me break it down. So, so we can

17:02understand what's actually happening in

17:04here. Okay, let's go step by step. So,

17:06the first part is hash x. That hash

17:10symbol is the stringification operator.

17:13It just takes whatever you pass in and

17:15turns it into a string. So if you write

17:18net f position for example

17:22that hash x becomes a string holding

17:26position. Simple enough. It's just a

17:29field name as a string. Okay. The second

17:32part is

17:34where it gets scary if you are a Python

17:37developer.

17:40This is calculating the bite offset of a

17:42field within the strct.

17:47Now let me walk you through this step by

17:50step because there's a really clever

17:52trick happening here. Okay. So first

17:54let's take a look at this. This is a

17:57cast. What this does is it takes the

18:00number zero which is the null pointer

18:03and casts it to a pointer to a entity

18:07statect.

18:09So we are pretending that there's a

18:11entity state strct sitting at memory

18:14address zero.

18:17Obviously there isn't really a strct

18:20there but the compiler doesn't know at

18:22this point. Okay. Next we have this x.

18:27This accesses the field we are

18:30interested in. So if we call net f

18:33position this becomes position. We are

18:37accessing the position field of this

18:40imaginary strct at address zero.

18:44And then comes the end operator. This

18:46takes the address of that field. Now

18:50here's the clever part.

18:54If our strct starts at address zero

18:58and we take the address of a field

19:02inside it,

19:04that address is

19:07the offset of the field from the

19:09beginning of the strct.

19:14If the position field is 12 bytes into

19:17the strct,

19:20its address when this truck is at

19:22address zero is then 12. Okay, does this

19:26make sense?

19:29Probably not, but it's a very elegant

19:32way and you will see why why this is

19:34important. And finally we cast this

19:37whole thing to a integer. So we get a

19:39nice integer offset value. Okay. So the

19:44compiler is doing all this math at

19:46compile time to figure out where each

19:49field lives within the strct. And this

19:52becomes really powerful and you will see

19:54it. Okay. Right below that you see the

19:57actual table with the entity state

20:00fields. And this is where we use this

20:03net f.

20:05Okay. I mean,

20:09look how clean this looks like. Even

20:12though you can't probably understand

20:13what's happening here, it looks really

20:15nice. So each line is just a field name

20:20and wrapped in this netf followed by the

20:23number of bits to use for encoding.

20:27And the compiler

20:29now processes each of these net net f

20:33macros and calculates where that field

20:36actually lives in the entity state

20:39strct.

20:41So each entry ends up with three things.

20:45the field name, the bite offset from the

20:49start of the strct, and the number of

20:52bits to use for encoding

20:54that field over the network.

20:57If you're wondering now, what's this

20:58last bit for? That last bit just tells

21:01the network code how much precision to

21:04use when sending this value.

21:08Okay. So if you scroll up you will see

21:11that uh yeah value of zero means it's a

21:16float and should be encoded

21:19accordingly

21:21while other values for example like 32

21:23specify how many bits to use for these

21:27integers. Okay for I don't know

21:31timestamps or something. Now you may be

21:34thinking or maybe not but Tariq

21:39couldn't we just hardcode these offsets?

21:43Why do we need this

21:46sorcery? And that's actually a really

21:49great question because the answer

21:50reveals why this approach is

21:55genius.

21:56Yeah, I mean you probably wouldn't come

21:59up with this approach to solve this type

22:01of issue. And that's why it's very

22:04important to study these code bases

22:06because this isn't something a

22:07university teacher would necessarily

22:10teach you in his programming class. I

22:13mean, sure, you could write something

22:16like, I don't know, just hardcode all

22:19the offsets and that works fine until

22:22you need to change the strct. And you're

22:25going to have to change the struct a

22:27lot. I mean, game development is very

22:30iterative. You're constantly adding new

22:34fields, removing old ones, reordering

22:36things for performance or changing data

22:40types or something. And uh

22:44let's say you added a new field on top

22:46of this entity state. Now, for

22:52every single offset in your hard-coded

22:55table, you would need to make a change.

22:57And for example, health isn't anymore at

23:01offset 48, and 52 offset is wrong for

23:04the weapons. Position moved, angles

23:07moved, everything shifted.

23:10So now you have to manually go through

23:12your entire table and recalculate every

23:16offset by hand. I mean you could of

23:18course script this part by doing it with

23:21this approach. You just recompile and it

23:24automatically recalculates all the

23:26offsets for you. The compiler knows the

23:29strct layout. So it figures out where

23:32everything is. You add a field to the

23:34strct. You add one line to the table

23:36using the net TF and you're done. No

23:40manual offset calculation. So no chance

23:43of getting anything wrong. Now besides

23:45that we have another advantage when it

23:48comes to struck padding and alignment.

23:50As you know sometimes the compiler adds

23:53invisible padding byes between two

23:55fields to align them properly for

23:57performance. So you might think two

24:00integer fields are eight bytes apart but

24:03the compiler added four bytes of padding

24:06for whatever reason. So now they're

24:09actually 12 bytes apart. But with this

24:12approach, you don't have to think about

24:15any of this. The compiler just

24:17calculates it and you get the offset.

24:20Okay. And there's of course the thing

24:22with the portability. I mean, different

24:25compilers on different platforms might

24:28lay out the trucks differently. They

24:31might use different padding rules or

24:33different alignment requirements. And

24:36with hard-coded offsets, your table

24:38would be wrong on some platforms. So

24:41this isn't about something you couldn't

24:44do otherwise.

24:46It's more about this not having to be

24:48tedious and not having to do this

24:51errorprone manual work every single time

24:53you change something because you put the

24:56responsibility on the compiler to do

24:58this let's call it maintenance stuff.

25:01Okay. Now let's see how this table

25:04actually gets used. If you scroll down

25:07now further in the message.c C file

25:10you'll see this message write delta

25:13entity function. This is where it gets

25:16interesting. So this function takes two

25:19entity states the old one and the new

25:21one and then figures out what has

25:23changed. It starts by defining a bunch

25:26of stuff and then we get to this loop.

25:30So this function has two very important

25:34loops and the first one goes through the

25:37fields and looks for the last changed

25:40field. That's this LC here.

25:44Why is this important?

25:46Well, imagine you have a series of

25:48fields

25:5010

25:52and then a lot of zeros.

25:56Now every bit is a field. One means we

26:02have a change and zero means we have no

26:05changes.

26:07Now this LC variable

26:10marks the spot so to say where the last

26:13change is located in the entity fields

26:18and it tells the rest of the function

26:20then okay here is where the last changed

26:23field is and after that everything is

26:26unchanged. So you can cut this tail off

26:31drop the tail like a lizard. Okay. And

26:35then we have these checks. If nothing

26:37has changed, you can return immediately.

26:39There's no point in comparing fields if

26:42there are no changes. But if there are

26:46changes,

26:47then comes the second loop.

26:50And in this loop, we go through all the

26:52fields and compare all the values and we

26:55write the delta bits. Okay? And

26:59generally speaking, the loop doesn't

27:01really know what it's comparing.

27:04And that's that's elegant about it.

27:08Okay, so the delta compression really

27:10scopes out only the information that

27:13really needs to be sent. In practice, it

27:17looks something like this.

27:20Let's say we have a player entity that

27:22moves slightly and changed weapons.

27:26The old state position is 100 250.

27:31Weapon is 2.

27:34Animation frame is 15.

27:36The new state position is 105, 250.

27:42Weapon is three. And the animation frame

27:44is still 15. Now the delta compression

27:49sees that only the X position and the

27:52changed weapon. So it only sends these

27:55two fields. And now that's maybe 16 bits

27:59instead of hundreds of bits for the full

28:02entity state it would take. But we are

28:05still not done with the bit squeezing.

28:07So if you think about it, the PVS

28:09filtering and the delta compression

28:12scope out the data that we definitely

28:15need to send in the next game state

28:17update. And since we can't filter out

28:20any more unnecessary information, the

28:22only thing that we can do is to try to

28:25represent these update bits with fewer

28:29bits. And that's where Huffman

28:31compression comes in. So after that's

Huffman Compression

28:34done, we have this stream of bits that

28:36we want to send in the next game update.

28:39So we run it through the Huffman

28:40compression. Now let me explain how the

28:44Huffman compression actually works.

28:46We're going to start with a small

28:47example and then I'm going to take a

28:49look at the code. So, Huffman

28:51compression is basically like creating a

28:53key map for your data. Instead of every

28:56bite taking exactly eight bits, we

28:58represent the most common bite values

29:00with just one or two bits. While very

29:04rare values can take up more than eight

29:06bits. But since the rare stuff is well

29:10not being sent out that frequently, you

29:12end up saving tons of data overall. So

29:16what they've done is they analyzed the

29:18traffic during normal gameplay and found

29:20out that specific values are sent more

29:23frequently than others. They analyze the

29:26bite frequencies and then you get

29:28something like this. For example, zero,

29:31which basically means no change, appears

29:3440% of the time because most things

29:37don't change most of the time. Then one

29:40representing tiny movement deltas appear

29:4325% of the time. A two for example like

29:47a slightly bigger movement shows up 20%

29:49of the time and very big movements like

29:53teleporting appear only 15% of the time.

29:56These are of course not the actual

29:58values from Quake. I'm just giving you

30:00an example. Now in case you're wondering

30:03how do you determine which value gets

30:05which key and how do you avoid

30:07ambiguity? Well, it's kind of simple. In

30:11our imaginary example, we have these

30:13four separate nodes, zero, 1, 2, and FF.

30:19Now, here's where the Huffman algorithm

30:22comes in. The rule is very simple.

30:24Always merge the two nodes with the

30:26lowest frequencies. So, first, we're

30:29going to take FF at 15% and the two at

30:3220% and combine them. Next, we're going

30:36to take the one at 25% and our new node

30:39at 35% which we created in the step

30:42before and combine these two. And now in

30:46the final merge, we combine zero and the

30:50node two to create the full tree. Okay.

30:53Now we assign the codes by following

30:57edge labels. Left edges are zero, right

31:00edges are one. So first let's start with

31:03zero. So we go left. Left is zero. And

31:06we end up in the leaf. So zero is zero.

31:10Now we want the one. So we go right

31:13which is one. And then left which is

31:15zero and end up in the leaf. 1 zero. FF

31:20we're going to go right right left which

31:23is 1 1 0. And then for the two we go

31:27right right one one one. And here's the

31:30beautiful part about why this works

31:32without any ambiguity. Let's try to

31:35decode the bitstream. 0 1 0 1 1 0 1 1.

31:40Okay, we start at the root. We read the

31:43zero. We go left. End up in the leave.

31:47And then we reset to the root. We read

31:49the one. We go right internal node. That

31:52means we continue. We read a zero. We go

31:56left. And we end up in the leaf again.

31:59reset to the root. Now we read a one, go

32:02right, then again one, go right, then

32:05read zero, go left. We end up in the

32:07leaf and we reset to the root again. Now

32:10we continue doing this until we decoded

32:13the whole bit stream. And the result is

32:16a perfectly decoded bitstream with zero

32:20ambiguity. Now to demonstrate how much

32:22compression can we achieve with this

32:24Huffman compression. For example, if you

32:27send five bytes, these five bytes

32:30without Huffman that's 40 bits. With

32:33Huffman, it's only 11 bits. So we end up

32:36saving over 70% of data. Now let's take

32:40a look how Quake tree actually

32:42implements this. So Quake Tree uses two

32:47different approaches. an adaptive

32:50Huffman and a precomputed Huffman. It

32:53uses the adaptive for the initial

32:55connection handshake. It is located in

32:57the Huffman. C file. So for adaptive, it

33:01uses this half compress function. They

33:05used precomputed Huffman trees for

33:08regular gameplay packets. Let's open

33:10code q message. C.

33:15Now in message C we have this premputed

33:20frequency table called message huffman

33:24data.

33:26So when starting the game you call

33:29message in it Huffman

33:32and that builds a Huffman tree from this

33:34frequency table.

33:36But how did they use this in the code?

33:39Well, when the game reads or writes bits

33:42in the message functions, it roots the

33:44data through the Huffman encoder if

33:47compression is enabled. Of course, since

33:50the client in the server use the same

33:52Halfman tree, there is no discrepancy

33:54when the payload needs to be coded or

33:57decoded. All of this works because game

34:00network data is a very predictable

34:03pattern. you have always a lots of zeros

34:05from delta compression and you have

34:07small integers for coordinates. So

34:10certain values appear way more often

34:14than others. I mean if the frequencies

34:16were all the same if you have like a

34:19even distribution of the values. It

34:22wouldn't make sense integrating the

34:24Huffman compression in all of this.

34:26Okay. So that means they integrated this

34:29Huffman compression directly in the read

34:32bits and write bits functions. Now

34:35generally speaking the compression

34:37pipeline is highly efficient. PVS cuts

34:40out maybe 80% of the data you don't

34:42need. Delta compression reduces what's

34:45left from hundreds of bits per entity

34:47down to maybe 20 or 30 bits on average.

34:50And then Halfman squeezes that remaining

34:53data by another 50 or 70% by exploiting

34:56the patterns in game data. You end up

34:59taking what should have been multiple

35:01megabytes per second and turning it into

35:04a few kilobytes per second. And the

35:07crazy part is this isn't just clever

35:11compression. It's compression that's

35:13specifically designed around the

35:15patterns that actually exist in real

35:17game data. They didn't just apply

35:19generic compression algorithms. They

35:22built compression that understands what

35:24Quake tree network traffic actually

35:28looks like. And that's why you got that

35:30much compression. All right. So, we've

35:33solved the bandwidth problem, but now we

35:36add the second fundamental constraint,

35:38latency. And this one's a real

35:42mindbender because the solution involves

35:44something that sounds a bit

35:46counterintuitive.

35:47letting every player live in their own

35:50personal timeline from a firstperson

35:53perspective. Now, before we continue, a

35:56word from our today's sponsor,

35:58Brilliant. Instead of refreshing your

36:00inbox for the daily job rejection email,

36:03since apparently nobody is hiring

36:05anymore, just get Brilliant. It's got

36:07interactive courses on math, data

36:09analysis, and computer science that

36:12actually make your brain feel good. I

36:14use it when I need to recover from

36:16making spaghetti and not the Italian one

36:18or when I want to sound clever around

36:20friends who still think I fix printers

36:23for a living. The best part is you can

36:25start from zero. Brilliant takes you

36:27from the basics and builds up step by

36:30step. Even if your attention span has

36:32been reduced to the length of my last

36:34relationship, it's basically an arm

36:36workout for the brain. To learn for free

36:39on Brilliant, go to

36:40brilliant.org/tariq10x.

36:42Scan the QR code on screen or click on

36:45the link in the description. Brilliant's

36:47also given my viewers 20% off an annual

36:50premium subscription which gives you

Latency

36:52unlimited daily access to everything on

36:55Brilliant. Now back to the video. Okay.

36:58Now the problem with latency is when you

37:00press a key the signal has to travel to

37:03the server. The server has to process it

37:06and send back the result. And only then

37:08you can see your character move. That's

37:11what we call a full round trip. And on

37:13the internet from the '9s, we're talking

37:16about delays that make the game feel

37:18like you're controlling your character

37:20via screen sharing. There is just no way

37:23to speed it up with elegant software

37:25tricks to lower the latency. If a train

37:28has a top speed of 50 mph, it doesn't

37:31matter how light the fright is that the

37:34train is carrying, it can't go any

37:36faster. So they had to accept that as a

37:39hard limitation and create something

37:41that will give you the illusion that the

37:43game is super responsive. Now their

37:46solution breaks down into two core

37:49mechanisms that attack different aspects

37:52of the latency problem. First we have

37:55client side prediction which makes your

37:57character feel instantly responsive by

38:00running the game physics locally.

38:03Second, we have interpolation and

38:05extrapolation which make other players

38:08look smooth despite receiving choppy

38:10updates from the server. Together, these

38:13two create the illusion that latency

38:15doesn't exist. Now, I have talked in

38:18another video about the client side

38:19prediction, and I have to mention that

38:21Quake wasn't the first game that

38:23implemented it. Ken Silverman was

38:26actually the first one to implement it.

38:28The creator of the build engine which

38:30powered Duke Nukem 3D. He implemented

38:33clientside prediction in January 96, 7

38:37months before John Carmarmac added it to

38:39the Quake World in August 96. You can

38:43also find it in the Duke Nukem source

38:45code and I might be making few more Duke

38:48videos in the future. In my opinion,

38:50it's genius even though it looks like a

38:53very normal concept nowadays. Okay, so

38:56the core idea is very simple. When you

38:59press a movement key, instead of sending

39:01that input to the server and waiting for

39:04permission to move, your client

39:06immediately runs the exact same movement

39:09physics that the server uses. So you see

39:12yourself move instantly even though the

39:15server hasn't even received your input

Client-Side Prediction

39:17yet. Now let's take a look at the code.

39:20If you open code C game CG predict,

39:24there is a function called CG predict

39:28player state that runs every single

39:31frame. This is the heart of the

39:33prediction system. I'm going to walk

39:34through this, but on a very high level.

39:38As you can see, there are many comments

39:40in this part of the code. That's usually

39:42a good indicator that a section is more

39:45complicated than the rest in good

39:48projects, of course. Bad projects are

39:51full of useless comments. Okay, so the

39:55client needs to figure out which

39:56commands it needs to execute, what to

40:00predict. Every time you press a key or

40:02move your mouse, the client creates a

40:04command, a little packet of data that

40:07says at this moment in time, the player

40:09wanted to move forward or turn left or

40:12whatever. The server sends back

40:14snapshots to tell you, I've processed

40:17everything up to command number, let's

40:20say, nine. So when the predict system

40:23runs, it looks at the acknowledgement

40:25and says, "Okay, the last thing that the

40:29server confirmed was command number

40:31nine, but I'm currently at command 14

40:33because I've sent five more commands

40:36since then. So, I need to replay

40:38commands 10, 11, 12, 13, and 14 to

40:42figure out where where I should be right

40:44now. Okay,

40:47I'm not going to dig deep into every

40:49line here. So, I'm obviously skipping

40:51some parts. And then at some point, we

40:54arrive at this loop. This loop is

40:59walking through all those unacknowledged

41:02commands one by one. And for each

41:04command, it calls the trap function. In

41:07my last Quake video, I talked about trap

41:08functions if you remember. Trap get user

41:12command to retrieve the command from the

41:14buffer. And then it calls pove. That's

41:17the physics function. And here's the

41:20beautiful part. Pove is the exact same

41:25code that runs on the server side and

41:28it's located in this pove. C source

41:31file. So both client and server link to

41:36the same file. So when your client runs

41:39pove with the command number I don't

41:41know 11, it gets the same exact result

41:46as the server got when it processed

41:48command number 11. The pove function

41:52takes this pove strct as an input

41:56and this strct contains basically

41:58everything you need to run the physics.

42:01It contains the player state, the

42:04command to execute and everything. And

42:06that's the essence why the prediction is

42:09so accurate. So it's not guessing, it's

42:12not a gamble. It's literally running the

42:15same physics simulation with the same

42:17input so it gets the same output.

42:20And then when the update arrives, the

42:22clan just checks if it's been right. And

42:24if not, it starts a reconciliation

42:26routine to get back on track. Now,

42:29client side prediction solves the

42:31problem for your own character. But what

42:34about everyone else? This is where

42:37interpolation and extraolation come in.

42:39And this is solving a completely

42:42different aspect of the latency problem.

42:45So for example, the server is sending

42:47you snapshots at maybe 20 frames per

42:50second, but your game is running at 50

42:53frames per second. If you just displayed

42:56each snapshot exactly when it arrived,

42:59other players would be like teleporting

43:02around. And that's a 100% valid reason

43:06to smash your keyboard. I mean the

43:08client doesn't display the latest

43:10snapshot immediately. Instead, it

Interpolation

43:12deliberately runs about 100 milliseconds

43:15behind which gives it two snapshots to

43:19work with and then it smoothly

43:21interpolates between them.

43:24You can see in

43:27code came

43:29CG ants. C we have this function

43:32interpolate entity position. Okay, so

43:36this function is pretty straightforward.

43:39It takes two snapshots, the current one

43:41and the next one, and blends between

43:43them.

43:45The function uses this pre-calculated

43:48interpolation factor F and which is

43:51basically a number between 0 and one

43:53that tells you how far along you are

43:57between the two snapshots. Meaning if

44:00it's zero, you're exactly at the first

44:02snapshot. And if it's one, you're at the

44:05second snapshot. If it's 0.5, you're

44:09halfway between them. Now, here's how it

44:11works. First, it evaluates where the

44:16entity should be in both snapshots with

44:19this bg evaluate trajectory.

44:24This takes the trajectory data from each

44:26snapshot which includes position,

44:28velocity, and movement type

44:31and calculates the exact position at

44:34that moment in time. This handles things

44:37like entities that are accelerating or

44:41following curved paths. Then it does a

44:44simple linear interpolation between

44:46those two positions using that

44:49interpolation factor F that I mentioned.

44:52And then it does the same exact thing

44:54for the angles. So basically same idea.

44:58Evaluate the angle trajectory for both

45:00snapshots and then interpolate between

45:02them using the same interpolation

45:05factor. So the client is constantly

45:08sliding entities from where they were to

45:11where they're going and creating this

45:14illusion of continuous movement even

45:16though it's only receiving 20 updates

45:19per second. So, what's brilliant about

45:21this whole system is how it creates this

45:26illusion of instant responsiveness,

45:30smooth gameplay despite the fundamental

45:33constraint of latency.

45:36You get instant response for your own

45:38actions through the client side

45:40prediction.

45:41And you get smooth visuals because of

45:44interpolation and extropolation. And the

45:47price you pay is that everyone is living

45:49in a slightly different timeline. I

45:53mean,

45:54most people never even notice that it

45:58works like this. Okay. All right. So,

UDP Reliable Messages

46:00we've conquered bandwidth and we've

46:02beaten latency. But there is still one

46:05more fundamental problem with the

46:07internet from the '90s. Reliability. And

46:10this is where Quake 3 made perhaps its

46:13most counterintuitive decision. They

46:16decided to embrace packet loss instead

46:18of fighting it. Now, when most people

46:21think about network reliability, they

46:23think about TCP, the protocol that

46:26guarantees every bite will arrive in the

46:28correct order, no matter what. TCP is

46:32like a perfectionist postal service that

46:35will keep redelivering the same letter

46:37until you confirm you got it. and it

46:40won't deliver letter number two until

46:43leather number one is safely in your

46:45hands. For most applications, this is

46:48exactly what you want. But for a

46:50realtime game, this perfectionist

46:53approach is actually suboptimal. See,

46:56the thing about game data is that it has

46:59an expiration date. If a packet

47:02containing last frames player positions

47:04gets delayed by half a second while TCP

47:07tries to retransmit some earlier packet,

47:10that data isn't just late. It's actively

47:13harmful. You don't want to know where

47:15players were half a second ago. You want

47:19to know closely as possibly where they

47:21are right now. So Quake 3 made a radical

47:25choice. They threw out TCP entirely and

47:29built their reliability on top of UDP.

47:32UDP is like a much more relaxed mail

47:36service that just throws packets at

47:38their destination and hopes for the

47:40best. If a packets gets lost, well,

47:43maybe the next one will make it through.

47:46Now, if you're looking for reliability

47:48logic in the network transmission code,

47:51you're not going to find it there. Let's

47:53open the net channel code and look at

47:55the net channel transmit function. Okay,

47:58this right long takes the message and it

48:01adds a sequence number so you can detect

48:04packet loss and then this net send

48:09packet sends it over UDP and moves on.

48:13So where's the reliability?

48:17Well, it's built in at a higher level in

48:20the message system and both client and

48:23server have their own implementation of

48:25it. So on the server side, if you look

48:29at the code code server server main C,

48:34there's a function called add server

48:37command.

48:41This is where messages that absolutely

48:44must arrive get stored like

48:47player joined, map changed, or I don't

48:50know, server settings updated. These

48:53commands must go into the reliable

48:56queue. So, the server keeps these

48:59commands in a circular buffer and

49:02resends them with every outgoing packet

49:04until the client acknowledges them. On

49:08the client side, it works the same way,

49:10but in reverse. If you look at the code

49:14in code client

49:18clain C, there's this

49:21add reliable command. So this handles

49:26reliable messages from client to server

49:30things like changing your nickname,

49:32sending chat messages or requesting map

49:36downloads. So, we have the same circular

49:40buffer pattern, same same logic. Both

49:43sides keep resending their reliable

49:45commands until the other side says,

49:48"Okay, I've received it." But here's

49:50where it gets really interesting.

49:54How do you make a system work when most

49:56of your data is being sent unreliably?

50:00The answer is one of the most elegant

50:02pieces of engineering in the entire

50:04Quake 3 code base. It's the circular

50:07snapshot buffer.

50:09I mean, the idea is brilliant in its

50:12simplicity. Instead of trying to make

50:13sure every packet arrives, they designed

50:16a system where losing packets doesn't

50:18have a major impact. Both the server and

50:21the client keep just a rolling window of

50:23the last 32 snapshots they dealt with.

50:26So, when the server wants to send you a

50:28new snapshot, it doesn't just delta

50:30against the previous one. It can delta

50:33against any snapshot in your 32 slot

50:36window that it knows you received. Let

50:40me show you how this works. So in the

50:42code, if you open the server snapshot C

50:46file, there's this write snapshot to

50:49client. This is where the server decides

50:53which snapshot to use as the base for

50:55the delta compression. The server tracks

50:58which snapshot the client last

51:00acknowledged in client delta message. So

51:03if the client says I got snapshot 27 but

51:06snapshots 28 29 30 got lost. The server

51:10just delta snapshot 31 against snapshot

51:1327.

51:15In a traditional system this would be a

51:18disaster. You would either have to

51:20retransmit all the missing packets or

51:23send a huge full update. But the with

51:26this quake system, missing packets in

51:28between don't really matter that much.

51:31And the circular buffer is using this

51:34binary mastrix. So no complex buffer

51:38management is needed. On the client

51:40side, we have code client client

51:44parse.c.

51:45We have this client par snapshot

51:47function. The server tells the client

51:50which snapshot to use as the base. The

51:52client looks it up in its own circular

51:54buffer. applies the delta and

51:57reconstructs the current state. If you

52:00have any missing snapshots in between,

52:02it doesn't really matter. So, the end

52:05result is a system that's incredibly

52:06robust against network problems. It's a

52:09system that handles problems gracefully

52:12instead of failing catastrophically.

52:15So by designing around these actual

52:17requirements instead of trying to force

52:20a traditional reliability model, Quake 3

52:23created a network system that's both

52:25more robust and more efficient than

52:27anything based on TCP could ever be. And

52:30that's how they solved the reliability

52:32problem. It's the same kind of paradigm

52:35shift we saw with the bandwidth and the

52:37latency. Instead of fighting the

52:39constraints, they embraced them and

52:42designed around them. conclusion. You

Conclusion

52:45know, I'm sitting here looking at all

52:47this code and I'm thinking how wild this

52:51whole story really is. Like, imagine

52:54everyone's telling you that what you

52:56want to do just can't work. The internet

52:58is too slow, too unreliable, and too

53:02broken for realtime gaming. And

53:04honestly,

53:06they weren't wrong. I mean, the numbers

53:08were pretty brutal. But instead of

53:11giving up, these guys did something that

53:13still makes me smile when I think about

53:16it. It's just like having a drink with

53:18the boys and saying, "Hell yeah, let's

53:19let's do this." I mean, they looked at

53:22every single limitation and basically

53:25said, "Okay, what if we design around

53:29this instead of fighting it? What if we

53:32don't care about packet loss? What if we

53:34don't try to keep everyone perfectly

53:37synchronized all the time? What if we

53:39just embrace the chaos? And that's

53:42exactly what they did. Every technique

53:44we just walked through, the delta

53:46compression, the client prediction,

53:48Huffman, none of it came from having

53:51better hardware or faster connections.

53:54It all came from being clever about

53:57working with what they had. They turned

53:59their constraints into their competitive

54:01advantage. Innovation always comes from

54:05limitations. And the crazy part is how

54:08well it worked. I mean, we're still

54:10using these exact same ideas today. When

54:14you're playing Call of Duty or whatever

54:17game came out for $80 this month, you're

54:20experiencing the direct descendants of

54:22these techniques. 25 years later, and

54:25nobody's really come up with anything

54:29fundamentally better. And that's how you

54:31know something is truly revolutionary.

54:34When it doesn't just solve the immediate

54:36problem, but it becomes the foundation

54:38that everyone else builds on. The best

54:42solutions often come from the worst

54:45situations. When everything's working

54:47against you, when you have no choice but

54:49to think completely differently. Some of

54:52the most elegant code I've ever seen

54:56came from developers who had their backs

54:58against the wall and had to be creative.

55:00Anyone working in embedded knows this.

55:03It's easy to develop when your software

55:05has to run on 16 gigs of RAM. But how

55:09about I give you 4 mgabytes and I also

55:12want you to do a flash over the air with

55:14a AB swap. It makes you wonder what

55:17problems are we just accepting today

55:19because they seem too hard or too

55:21fundamental to change. What assumptions

55:24are we making that maybe we shouldn't

55:26be? Because if there's one thing this

55:28story teaches us is that sometimes the

55:31impossible is just waiting for someone

55:33brave enough to ignore what everyone

55:36else says can't be done. And I'm not

55:38trying to give you a motivational speech

55:40how everything is possible because we as

55:43humanity clearly can't even manufacture

55:46printers that work when we need them.

55:49Okay. Now I want to take the moment and

55:50say a big thank you to my patreons and

55:53YouTube members. Your support over the

55:55past year really helped me. Your

55:56memberships, messages, emails,

55:59everything. As you know, I'm

56:00continuously upgrading my setup, trying

56:02to improve the format, experimenting

56:05content-wise, trying out new ideas, and

56:08also listening to constructive

56:10criticism. Your comments and suggestions

56:13really help me to create better and

56:15better content. If you want to support

56:17the channel, there's a Patreon link in

56:19the description or you can become a

56:21YouTube member or just leave me a

56:24comment. So, that's it for today. If you

56:26like the content, hit like and subscribe

56:28and see you in the next one. Tik T.

Recently added transcripts

Browse the whole transcript library

This transcript was generated from the captions YouTube publishes for this video. Get the transcript of any YouTube video atfreeyoutubetranscribe.com, free, unlimited, no sign-up.