1. Game basics

This documentation largely focuses on game mechanics which involve the player one way or another. Nevertheless, the player doesn’t exist in a vacuum: it interacts with the engine and core aspects of the game. For example, the frame rate (see Frame rate) plays an outsized role in the player movement physics. In this page, we will describe only aspects of the engine and “lower level” game mechanics necessary to understand and exploit Half-Life for speedrunning purposes.

1.1. Versioning

Half-Life was first released in 1998. The game has since undergone many changes, though not in the core casual gameplay mechanics and campaign. Changes are usually confined to bug fixes, console command changes, small movement physics alterations, server administration improvements, and quality of life updates. Nevertheless, some ostensibly minor tweaks or bug fixes can neuter critical speedrun tricks and exploits. The most notorious of which is none other than the “bunnyhop cap” (see Bunnyhop cap). Community knowledge points to exe version 1.1.0.8 released in 2001 as the first version carrying the “fix”. As of 2026, all subsequent versions have the bunnyhop cap, and it is very unlikely Valve will officially remove it.

In speedrunning, the term “version” is ambiguous and usually context dependent. When we talk about Half-Life versions, we are usually concerned with how the version impacts game mechanics (such as the aforementioned bunnyhop cap). The most pertinent is the engine version. The Valve developer community wiki contains a page listing out all Half-Life engine versions. On the page, observe that there isn’t a singular “version” as far as the engine is concerned. There are at least the following versioning dimensions:

  • engine or exe build number, e.g. 8684

  • exe version string, e.g. 1.1.0.8

  • protocol version, e.g. 48

The most precise way to refer to a specific official release of the game is by referencing the build number. To find out the build number of a game, we could launch the game and run the version command in the developer console, or reverse engineer the hw.dll to examine the value returned by the build_number function.

Many engine builds share a common set of characteristics which distinguish them from other builds. To help distinguish the most important characteristics among the engine builds, speedrunners categorise them most broadly as WON and Steam. Broadly speaking, the WON versions of Half-Life refer to versions of the game prior to being released on Steam, while Steam versions are those released on Steam. We can further divide Steam into pre-SteamPipe, SteamPipe, and 25th anniversary. Speedrunners adopt the following mental model to identify the distinctive characteristics of each category.

WON

Maximum frame rate of 100 fps. Version 1.1.0.7 and earlier do not have the bunnyhop cap, while 1.1.0.8 and after do. No quickgauss (Quickgauss). No easy repeated action scripts with _special. The first crowbar hit has a higher damage.

Steam, pre-SteamPipe

Maximum frame rate raised to 1000 fps. All builds under the pre-SteamPipe category have the bunnyhop cap. Quickgauss works. Scripting with _special possible. The “501 fps slow down” trick works (see Slowdown on older engines).

Steam, SteamPipe

No limit to frame rate with some nuances detailed in Frame rate, but the code change caused the 501 fps slowdown trick to stop working. Scripting with _special disabled by Valve despite pleas from the community. Later versions since some time in 2019 fixed an issue where NPCs turn very slowly at higher frame rates.

Steam, 25th anniversary

Object boosting and manoeuvring (Object manoeuvre) no longer works. Use key braking removed entirely. The first crowbar hit has a higher damage similar to the WON versions.

This categorisation method will likely stay unmodified indefinitely. As Valve rarely intentionally reverts bug fixes, and the 25th anniversary is generally considered unattractive for speedrunning, future releases of Half-Life will very likely be ignored by the community and therefore do not call for new categorisation.

1.1.1. Version preference

Manual speedrunners or RTA runners generally prefer the WON version, with an important nuance which we will describe in the subsequent section. This is primarily due to levelling out the hardware playing field by forcing a cap of 100 fps, lack of bunnyhop cap, and lack of later “fixes” to NPC turn rate and object boosting. The lack of the extremely powerful quickgauss in WON is apparently not a big enough downside for RTA speedrunners, especially in light of uncapped bunnyhop. Exploiting the “infinite health door” in the Surface Tension chapter also takes several more seconds in WON compared to Steam, but the community considers this an acceptable trade off.

Tool-assisted speedrunners, however, generally prefer the Steam versions. Tooling is generally better for the Steam versions: for one, Linux binaries with DWARF debug symbols are only available in Steam versions. Official vanilla untouched versions are also considered, at least by the author of this documentation, to be “purer”, compared to what is commonly done for the WON version used by RTA runners (again, see Game/DLL version). This matters more to TASes as they run a higher risk of being accused of cheating. The deterrence of bunnyhop cap also matters less because the player could still maintain movement speed by precise ducktapping. Quickgauss is also much more exploitable in a TAS because inhuman precision is required to control the player at extreme speeds made possible by it. Many would consider creating a TAS on a WON version to be not making full use of the precision afforded by tool assistance. Most importantly, Steam versions allow pinning the frame rate at 1000 fps, which maximises strafing acceleration (see Strafing).

As this documentation is primarily focused on TAS applications, we will prioritise our analysis of Half-Life game mechanics on the Steam versions, particularly those before the 25th anniversary changes. As mentioned earlier, the changes brought upon by the 25h anniversary version are poorly received by the speedrunning community. The lack of object boosting to instantly max out the movement speed is too big of a trade off in exchange for nothing of speedrunning value. The inability to perform use key braking is also a deal breaker for high-speed precise TASing.

At the time of writing, the last Steam versions generally used for speedrunning are build 6153 and perhaps build 8684 or earlier. There is nothing fundamentally special about build 6153, except that it is the first SteamPipe version (according to tribal knowledge) and it comes with the commonly used GoldSrc Package 2.4 circulated among speedrunners.

1.1.2. Game/DLL version

Unfortunately, there is one aspect of Half-Life which makes matters even more confusing. Broadly speaking, the game is modular, consisting of an engine component and a “game/DLL” component. The engine component refers to the following binaries (and their macOS and Linux equivalents):

  • hw.dll

  • sw.dll (if present)

  • hl.exe

Meanwhile, the “game/DLL” component refers to the following and their equivalents:

  • hl.dll

  • client.dll

  • opfor.dll (for Half-Life: Opposing Force)

In vanilla Half-Life, this distinction is less critical because when Valve releases a “build”, it comes bundled with the corresponding game/DLL component, which may or may not contain code changes specific to that release. However, in modding and speedrunning, the game/DLL of one release may be swapped for the game/DLL from a different release. There are multiple reasons for doing this.

The primary reason is that the game/DLL component is source-available on Github as the Half-Life SDK (HLSDK). Modding sometimes entails modifying the source code then distributing the compiled binaries as modified hl.dll and client.dll, often on ModDB. Sometimes, a mod only distributes a mod folder (akin to the valve folder) containing only the game/DLL component. The player could easily and conveniently install this into an existing Half-Life installation, which could be running any engine build.

In Half-Life speedrunning since the mid-2010s, this swap is often a solution for those who wish to speedrun on the WON version but are hindered by compatibility and tooling issues on modern systems when running the original game outright. Since many desirable characteristics, such as the absence of bunnyhop cap, are determined by the game/DLL component, a runner can take a more modern Steam-based engine and swap in the older WON game/DLL, creating a “mixed breed” setup.

There is, however, a risk that a runner using a mixed breed version might configure settings that are impossible in any vanilla version. For instance, increasing the frame rate beyond 100 fps while bunnyhopping without a cap. This impossible combination was used to produce the landmark and esteemed HL21 segmented speedrun, which modern community standards have since reevaluated as not wholly legitimate. To prevent this, the community established rules to ensure a speedrunning experience that remains as close to the original unmodified WON release as possible, while retaining the quality of life and compatibility updates of the Steam engine. The results of this approach can be seen in the GoldSrc Package and Half-Life 2005 Package.

Since Valve do not make large changes to Half-Life and speedruns have become ever closer to optimality, we do not expect the above setups and arrangements to change meaningfully for, perhaps, forever.

1.2. Tracing

Tracing is one of the most important computations done by the game. Tracing is done countless times per frame, and it is vital to how entities interact with one another.

Note

expansion needed

1.3. Randomness

The Half-Life universe is full of uncertainties, much like our universe at the level of quantum mechanics. Randomness in Half-Life is sourced in two ways: by means of the shared RNG and the non-shared RNG. These are custom written pseudo-RNGs that are powered by vastly different algorithms. The shared RNG is so named because it is computed by the game server and shared with the game clients, while the non-shared RNG is computed independently by the game server and clients without any kind of sharing or synchronisation between them.

1.3.1. Shared RNG

The shared RNG code is open source and written in dlls/util.cpp in the Half-Life SDK. The shared RNG barely qualifies as an RNG given how it is used, and especially due to the fact that, given a fixed interval [𝑙,), the RNG only returns 253 possible values within the bounds, as we will explain below. The only uses of the shared RNG in Half-Life are related to weapon behaviours and bullet spreads (see Bullet spread).

For some context, a typical pseudo-RNG must be seeded prior to use, for a pseudo-RNG needs to have its initial state defined. To put it differently, let 𝑆0 be the initial state of a typical pseudo-RNG. To use this RNG, we must first call a seeding function 𝑆0 Seed(𝑠) with some value 𝑠, which is often just the current unix timestamp. Then, the next pseudorandom number is given by 𝑥0 where (𝑆1,𝑥0) 𝑓(𝑆0). In general, the 𝑖-th pseudorandom number is given by (𝑆𝑖+1,𝑥𝑖) 𝑓(𝑆𝑖).

However, the Half-Life shared RNG is used differently. A “seed” in this context refers to an integer that appears to increment sequentially every frame. This integer is stored as the class variable CBasePlayer::random_seed. This variable is set in CmdStart to the value of its random_seed parameter:

Listing 1.1 CmdStart, dlls/client.cpp
void CmdStart( const edict_t *player, const struct usercmd_s *cmd, unsigned int random_seed )
{
  entvars_t *pev = (entvars_t *)&player->v;
  CBasePlayer *pl = dynamic_cast< CBasePlayer *>( CBasePlayer::Instance( pev ) );

  [...omitted...]

  pl->random_seed = random_seed;
}

SV_RunCmd in the engine code supplies the value of the seed to CmdStart. The ultimate source of the seed value appears to be dependent on the latest incoming sequence number of the client-server channel. This part of the code is not open source, and therefore not well researched. Nonetheless, empirical and field evidence shows that the seed value obtained in CmdStart appears to be sequential from frame to frame, or at least, increments in a very predictable way.

The shared RNG may be denoted as 𝔘𝑆(𝜎,𝑙,), where 𝜎 is an integer, while 𝑙 and are floating point numbers representing the lower and upper bounds of the output, forcing the function to give a value within [𝑙,). The current shared seed value is typically given for 𝜎, although there are exceptions, such as in the computation of bullet spreads as explained in Bullet spread. In the SDK code, 𝔘𝑆 is simply UTIL_SharedRandomFloat. [1]

The most important aspect of the shared RNG is that it returns only 253 possible values for a given interval [𝑙,). The reader is encouraged to read the SDK code for the implementation details. For a higher level overview here, when UTIL_SharedRandomFloat is called, it always initialises a global glSeed to one of the 256 possible values according to a 256-element lookup table. The previous value of glSeed prior to calling this function is completely discarded as a result. The index to the lookup table is computed by taking the lower 8-bits of the sum of the arguments of UTIL_SharedRandomFloat reinterpreted as 32-bit signed integers. What follows are computations involving glSeed and scaling of the output according to the bounds. Notice that because there are only 256 possible initial states, followed by deterministic and pure computations, there can only have a maximum of 256 possible output values. In reality, it is slightly worse than that: we counted the number of unique output values from 𝔘𝑆 (given fixed 𝑙 and ), and there are only 253 of them. It is therefore quite a stretch to describe the outputs of the shared RNG as “random”.

1.3.2. Non-shared RNG

The code for the non-shared RNG is not officially publicly available. Nevertheless, we do not need to resort to reverse engineering as the C++ code for the non-shared RNG is available in the Xash3D engine code, the ReHLDS project, and the leaked Half-Life 2 source code, all of which look almost identical. The non-shared RNG is considerably more complex than the shared RNG. The non-shared RNG is used much more in Half-Life than the shared RNG. Examples of the uses of the non-shared RNG include the randomisation of the player’s explosion target position, grenade tumbling velocities, delays between entity “thinks”, NPC talking sequences and general behaviours, the pitches of sounds, cosmetics and effects, and much more.

Given the complexity of the non-shared RNG algorithm, we will not attempt to describe how it works here. We can say that it appears to be seeded based on the current unix timestamp. This meant that, in principle, we can change the system clock and restart Half-Life to alter the random behaviours and phenomena in the game. There are two functions exposed to the users to obtain the next random value: the integer version 𝔘NS(𝑆,𝑙,) and the floating point version 𝔘NS(𝑆,𝑙,). Both of these rely on some global state 𝑆.

1.4. Frame rate

When we think of the concept of frame rate, or sometimes somewhat incorrectly referred to by its unit of measurement frames per second or fps, we think of the refresh rate of the screen when playing Half-Life. However, it is crucial to distinguish between three different types of frame rate:

rendering frame rate

This is the real-time rate at which graphics are painted on the screen, denoted as 𝑓𝑟 =𝜏1𝑟. This definition maps to what is normally thought of as the frame rate. The rendering frame rate is usually limited by fps_max in normal gameplay, though if host_framerate is set, then fps_max is ignored. Other factors can also limit the maximum frame rate, including, but not limited to, the “vertical sync” setting (in-game or otherwise) and fps_override.

game frame rate

This is the virtual rate at which the majority (with player movement being the important exception) of the game physics are run, denoted as 𝑓𝑔 =𝜏1𝑔. The game frame rate is typically in sync with the rendering frame rate, though not always. For example, suppose a computer is not able to render the graphics beyond a rendering frame rate of 500 fps, but host_framerate is set to 0.001. This forces the physics to run at a virtual 1000 fps, though because the screen does not update that frequently, the game appears to run twice as slow in real time.

player frame rate

The player frame rate is the virtual frame rate at which the majority of the player movement physics (see Player movement basics) are run, denoted as 𝑓𝑝 =𝜏1𝑝. The player frame rate roughly corresponds to the game frame rate. Depending on the engine version, whether the game is paused, and the value of the game frame rate itself, the player frame time 𝜏𝑝 may oscillate between different values, stay at zero, or be rounded towards zero to the nearest 0.001.

1.4.1. Slowdown on older engines

Suppose the game frame rate is higher than 20 fps. On older game engines, roughly before build 6027, the player frame rate equals the game frame rate rounded towards zero to the nearest 0.001, as mentioned above. Namely, we have

𝜏𝑝=1000𝜏𝑔1000.

Definition 1.1 (Slowdown factor)

The slowdown factor is defined as the fraction

(1.1)𝜂=𝜏𝑝𝜏𝑔=1000𝜏𝑔1000𝜏𝑔=𝑓𝑔10001000𝑓𝑔=𝑓𝑔𝑓𝑝.

When the slowdown factor is less than one, the actual movement speed of the player will be lower. The player’s position update described in Position update uses 𝜏𝑝 but runs at the rate of 𝜏1𝑔 Hz. Indeed, the real velocity of the player is directly proportional to 𝜂, since

𝐫𝐫𝜏𝑔=𝐫+𝜏𝑝𝐯𝐫𝜏𝑔=𝜏𝑝𝜏𝑔𝐯=𝜂𝐯.

For instance, a trick known as the “501 fps slowdown” was implemented in Half-Life 21 (see Half-Life 21 (2014) by quadrazid et al.) to permit opening and passing through doors in the Questionable Ethics chapter without stopping dead by the doors before they could be opened fully. The slowdown factor at 501 fps is 𝜂 =0.501. With this slowdown factor, the real velocity is roughly half the developer-intended player velocity.

It’s also worth noting that on pre-Steam versions of Half-Life and its expansions, the default frame rate is 72 fps (and some speedrunners believe it should not be exceeded), which would give a slowdown factor of 𝜂 =117/125 =0.936. It’s interesting that some retail releases made the player move roughly 7% slower than the intended speed.

The following theorem is a well known fact among speedrunners: by setting an appropriate value for the game frame rate 𝑓𝑔, the player would experience no slowdown.

Theorem 1.1 (No-slowdown frame rate)

The slowdown factor 𝜂 =1 if and only if 1000/𝑓𝑔 is an integer.

Proof. By the last equality in (1.1), 𝜂 =1 if and only if 𝑓𝑔 =𝑓𝑝 if and only if

1000𝑓𝑔=1000𝑓𝑔,

which is only possible if 1000/𝑓𝑔 is an integer.

1.5. Savestates

1.6. DELTA rounding

The DELTA mechanism is one of the ways Half-Life uses to save bandwidth in client-server communication. Data sent between them include, but not limited to, the player velocity, player position, viewangles (see Viewangles), entity positions, weapon states, and many others. Essentially, information about every entity and player input in the game. Curiously, the schema for all the data sent via the DELTA mechanism is stored as the delta.lst file under the valve or mod folder. To illustrate, the following code excerpt pertains to the player inputs:

Listing 1.2 Excerpt from delta.lst
usercmd_t none
{
  DEFINE_DELTA( lerp_msec, DT_SHORT, 9, 1.0 ),
  DEFINE_DELTA( msec, DT_BYTE, 8, 1.0 ),
  DEFINE_DELTA( viewangles[1], DT_ANGLE, 16, 1.0 ),
  DEFINE_DELTA( viewangles[0], DT_ANGLE, 16, 1.0 ),
  DEFINE_DELTA( buttons, DT_SHORT, 16, 1.0 ),
  DEFINE_DELTA( forwardmove, DT_SIGNED | DT_FLOAT, 12, 1.0 ),
  DEFINE_DELTA( lightlevel, DT_BYTE, 8, 1.0 ),
  DEFINE_DELTA( sidemove, DT_SIGNED | DT_FLOAT, 12, 1.0 ),
  DEFINE_DELTA( upmove, DT_SIGNED | DT_FLOAT, 12, 1.0 ),
  DEFINE_DELTA( impulse, DT_BYTE, 8, 1.0 ),
  DEFINE_DELTA( viewangles[2], DT_ANGLE, 16, 1.0 ),
  DEFINE_DELTA( impact_index, DT_INTEGER, 6, 1.0 ),
  DEFINE_DELTA( impact_position[0], DT_SIGNED | DT_FLOAT, 16, 8.0 ),
  DEFINE_DELTA( impact_position[1], DT_SIGNED | DT_FLOAT, 16, 8.0 ),
  DEFINE_DELTA( impact_position[2], DT_SIGNED | DT_FLOAT, 16, 8.0 )
}

For speedrunning purpose, it is not necessary to understand every field defined in the delta file. Nevertheless, it is important to be aware of the highlighted lines in Listing 1.2. To understand why, we first need to understand what DEFINE_DELTA means.

  1. The first parameter is the field name.

  2. The second is the field type.

  3. The third is the number of bits to represent the field value after converting to an integer type.

  4. The fourth is a post-multiplier which the receiver must multiply by to restore the intended value. This name is seen in the DWARF symbol in the Linux binary.

Take the definition for forwardmove as an example. We see that the type is defined to be DT_SIGNED | DT_FLOAT. The number of bits to represent the value is only 12 and the post-multiplier is 1. This means that the value of forwardmove when received by the server-side code is an integer in the range of

𝐹[2047,2047].

Let’s see why. Recall that a single-precision floating point number requires 32 bits. This implies that the forwardmove undergoes a lossy compression when sent across the network. To be more precise, DT_SIGNED | DT_FLOAT means that the value will first be converted into an integer, thus truncating it or rounding towards zero. Then, the integer value will be represented as a sign and magnitude bits, rather two’s complement. In particular, the integer will be clamped or clipped to fit in the magnitude bits. In addition, a post-multiplier of 1 means that the serialised value will be multiplied by 1 when received to restore the intended value, which implies the intended value is exactly what is sent on the wire to begin with. (The reader may verify these facts by reverse engineering the DELTA_* and MSG_WriteSBits functions in hw.so or equivalents.) Since 211 =2048, we obtain the aforementioned range. The analysis here clearly also applies to sidemove and upmove as per the highlighted lines in Listing 1.2.

Equally as important are the viewangles[*] fields. They have the data type DT_ANGLE, represented in 16 bits, with a post-multiplier of 1. According to MSG_WriteBitsAngle in hw.so, we may model the operation mathematically as follows.

Lemma 1.1

Let 𝛼 be the input angle. Assume 16 bits are used to serialise the value and a pre-multiplier of 1. Then the value on the wire via the DELTA mechanism is

˜𝛼𝑤=int(65536360(𝛼mod360))𝙰𝙽𝙳65535

where the meaning of int, mod on a real number, and 𝙰𝙽𝙳 are defined in Anglemod.

On the receiver side, the MSG_ReadBitsAngle is called to parse the wire format. Mathematically, it performs the following operation.

Lemma 1.2

Let ˜𝛼𝑤 be the angle value received via the DELTA mechanism. Assume 16 bits are used to serialise the value and a pre-multiplier of 1. Then the restored value is given by

˜𝛼𝑟=36065536˜𝛼𝑤.

We may now examine the end-to-end or round trip conversion. Putting Lemma 1.1 and Lemma 1.2 together, we obtain the function

(1.2)˜𝛼𝑟=36065536(int(65536360(𝛼mod360))𝙰𝙽𝙳65535).

Theorem 1.2

If 0 𝛼, then the end-to-end conversion given in (1.2) is equivalent to the degrees-anglemod function 𝔄𝑑 in Definition 3.5.

Proof. The only difference between the end-to-end conversion and 𝔄𝑑 is the presence of 𝛼mod360. By Lemma 3.2, any 𝔄𝑑(𝛼 +360) =𝔄(𝛼), which is equivalent to saying 𝔄𝑑(𝑥mod360) =𝔄(𝑥).

In Theorem 1.2 we only need to concern ourselves with nonnegative 𝛼. This is because the player viewangles supplied to the DELTA mechanism are always positive, as they are always first converted by 𝔄𝑑 itself before the DELTA operations, and 𝔄𝑑(𝑥) 0 for all 𝑥 . We will examine the properties of this function further in Anglemod.

Footnotes