skip to content
Pocket Change

Left 4 Dead 2: Building a Simple Aimbot via RE

/ 9 min read

Creating an Aimbot for Zombies

Instead of analyzing malware this time, I switched gears: I found Left 4 Dead 2 on sale on Steam, and I thought it would be a fun challenge to reverse engineer the game and build a simple aimbot for zombies.

One of the hardest parts in game hacking is reliably finding static addresses for key game objects like the player, entities, and health so that those addresses remain valid when reloading or restarting the game. This requires a mix of experience, experimentation, and understanding how game engines structure memory. Tools like Hazedumper can help later, but I prefer to fully understand things manually through Cheat Engine first.

Below is a walkthrough of how I found the player health and the entity list pointer. I spent around a month debugging the game on and off while writing the aimbot, so hopefully this summary saves others time.

Overall Goal

The goal of this aimbot is to automatically aim at the closest alive zombie by reading entity data from memory, calculating aim angles, and smoothly writing them into the player’s view angles to avoid unnatural snapping.

Finding Entity list pointer

Player Entity (CE)

To locate the player’s health, I used Cheat Engine and performed repeated scans, taking damage and rescanning until the results narrowed to a small number of addresses.

Left 4 Dead 2 uses both server.dll and client.dll. In single-player mode, the server runs locally, so modifying a value inside server.dll will update the actual in-game state, and the change will be reflected inside client.dll. However, the reverse isn’t true on client.dll simply mirrors the server state. (This is very useful when debugging. You always want to affect server-side values if possible.)

Player Entity — access

Next, I checked which instructions accessed this address. As mentioned earlier, both server-side and client-side instructions appear, since the health field resides at offset 0xEC, this helps identify which accesses correspond to the correct entity structure. The access counter also helps validate the address, as higher counts usually indicate active gameplay usage.

Pointer scan

To locate a static pointer, I performed a pointer scan on the dynamic address using the offset 0xEC. Some games require multiple pointer scans across restarts, but here I found two promising results inside client.dll. Both pointed to the correct location. Over time, one may become invalid after a patch, so I keep both candidates until one consistently fails.

Entity List (CE)

After locating the static player address, I checked what accesses it again. This time, the instruction used ebx*4 in the effective address calculation. Multiplying an index by 4 is a classic sign of an array of pointers in other words, an entity list. So here, ebx functions as the entity index, and the base address is the start of the entity array.

Find access address (CE)

I then searched for the player’s address inside memory and looked at which static pointers reference it. The green entries indicated valid static addresses. After checking manually, I confirmed that: client.dll + 73A574 is the correct entity list pointer.

Entity list — Reclass

ReClass helps visualize the entity list and its layout. Each zombie or player object occupies a fixed-size slot spaced 0x10 bytes apart and the slot containing 0x60, it’s empty. As new zombies spawn, the game fills these slots with entity pointers.

This live validation step is important, as it confirms the structure dynamically rather than relying solely on static analysis, you can see zombies appear, disappear, and shift through these offsets which confirms you’re looking at the correct structure.

Memory Model

client.dll
 ├── LocalPlayer
 ├── EntityList
 │    ├── Entity[0]
 │    ├── Entity[1]
 │    ├── ...
engine.dll
 └── ViewAngles
  • Client.dll holds the player, entity list, positions, health
  • Engine.dll holds the player’s view angles, which we can override to control the camera.

Client.dll contains the authoritative game state, while engine.dll consumes view angles to render the player’s camera.

Offsets

namespace off {
    constexpr DWORD client_entityList = 0x73A574;
    constexpr DWORD client_localPlayer = 0x726BD8;

    constexpr uintptr_t m_vecOrigin   = 0x124;
    constexpr uintptr_t m_viewOffset  = 0xF4;
    constexpr uintptr_t m_iHealth     = 0xEC;
    constexpr uintptr_t m_lifeState   = 0x144;
}

The following are the offsets:

  • client_entityList: base pointer for entity list
  • client_localPlayer: pointer to the local player
  • m_vecOrigin: entity position
  • m_viewOffset : eye height offset from origin (used to compute eye position)
  • m_iHealth: Health address of the entity
  • m_lifeState: alive/dead validation

Eye Position Calculation


static Vector3 eyePosLocal(uintptr_t local) {
    Vector3 o = mm.readMem<Vector3>(local + off::m_vecOrigin);
    Vector3 vo = mm.readMem<Vector3>(local + off::m_viewOffset);
    return o + vo;
}

To aim correctly, we need both m_vecOrigin and m_viewOffset. m_vecOrigin defines the world position, while m_viewOffset defines the eye height. Without it, an approximation can be used. However, zombie heights vary in practice, making this unreliable.

Angle Math

Let’s go over the geometry involved, as this part can be a little tricky. After finding the closest enemy, we need to calculate the angles required to aim correctly at the enemy’s head.


inline float Wrap180(float a) { while (a > 180.f)a -= 360.f; while (a < -180.f)a += 360.f; return a; }
inline float ClampPitch(float p) { if (p > 89.f)p = 89.f; if (p < -89.f)p = -89.f; return p; }

Ang CalcAimAngles(const Vector3& src, const Vector3& dst)
{
    const float dx = dst.x - src.x;
    const float dy = dst.y - src.y;
    const float dz = dst.z - src.z;
    const float hyp = std::sqrt(dx * dx + dy * dy);

    Ang a;
    a.yaw = Wrap180(std::atan2f(dy, dx) * 180.f / float(M_PI));
    a.pitch = ClampPitch(-std::atan2f(dz, hyp) * 180.f / float(M_PI));
    return a;
}

Calculating the view angle involves two angle calculations: The first one is to get the height you need to aim at and the second one is to get the direction view wise left to right.

tangent-contangen-angle.png To calculate the first one which is the height look up/down (Pitch): Pitch Angle

This graph will help demonstrate the strategy for getting the angle. The player and the enemy each have x,y,z coordinate. Given the coordinates for getting the height will be z and hypothesis referrencing the tangent fornula above. Pitch is calculated using the vertical difference (dz) and the horizontal distance (√(dx² + dy²)). Using trigonometry, the pitch angle is computed as: pitch = -atan2(dz, √(dx² + dy²)). The negative sign accounts for Source engine’s inverted pitch convention.

To calculate the second one which is the rotate left/right(Yaw):

Yaw Angle

Yaw Angle is the angle between the Y and X coordinate of the player and enemy. Hence getting the able left/right aim wise will be: Yaw is calculated using atan2(dy, dx), which determines the left/right rotation in the X–Y plane based on the difference between the player and enemy X/Y coordinates.

Wrap180 and ClampPitch are helpers functions to keep the values within source engine ranges to work correctly and avoid snapping.

Entity Filtering Logic

ClosestResult getClosestEnemy(uintptr_t local, uintptr_t ent, int index)
{
    ClosestResult r;

    if (!ent) return r;

    const int life = mm.readMem<int>(ent + off::m_lifeState);

    const int hp = mm.readMem<int>(ent + off::m_iHealth);
    if (hp <= 0 || hp > 5000) return r;

    int localTeam = mm.readMem<int>(local + off::m_iTeamNum);
    int entTeam = mm.readMem<int>(ent + off::m_iTeamNum);

    if (entTeam == localTeam) return r;

    const Vector3 pos = mm.readMem<Vector3>(ent + off::m_vecOrigin);

    const Vector3 me = mm.readMem<Vector3>(local + off::m_vecOrigin);
    const float d2 = distSq3D(me, pos);

    r.ent = ent; r.index = index; r.distSq = d2; r.hp = hp;
    r.aim = CalcAimAngles(me, pos);           
    return r;
}

Selecting the closest enemy is done by computing the distance between the player and each valid entity. There are multiple checks help eliminate dummy entites. The entity list contains as well the list of the other players so skipping them by checking the TeamNumber.

Entity Iteration

I reattached the reclass image for refrencing.

Entity list — Reclass

The entity list is a sparse array; not every slot is valid. Each zombie or player object occupies a fixed-size slot spaced 0x10 bytes apart. When a slot contains 0x60, it is empty. As new zombies spawn, the game fills these slots with entity pointers.

constexpr size_t ENTITY_SLOT_STRIDE = 0x10;

uintptr_t ent = mm.readMem<uintptr_t>(entMgr + i * ENTITY_SLOT_STRIDE);

We need to iterate through entity list 1… and multiply by 0x10 to jump to the next one.

Writing View Angles

Ang cur = mm.readMem<Ang>(vaBase + off::viewAngle);
const float smooth = 0.35f;
Ang next{
    ClampPitch(cur.pitch + (aim.pitch - cur.pitch) * smooth),
    Wrap180(cur.yaw + (aim.yaw - cur.yaw) * smooth)
};

Cur is the current angle of our player view. We add the diffenece of the calcuclated angle between closest enemy and the player. The smooth prevents sudden snapping and makes the movement smoother.

Demo

The following clip shows the current state of the aimbot. At this stage, the goal is correctness rather than refinement: validating entity selection, angle calculation, and smooth view manipulation under live gameplay conditions.

Current issues and limitations

uc_zombie.png

One of the main issues encountered involves common infected (regular zombies). As shown above, some common infected exist in an idle or dormant state and are not represented in the entity list used by the aimbot.

Even after these infected transition into an active state and begin attacking the player, they do not consistently appear in the iterated entity list. As a result, the aimbot fails to detect them both while idle and during active gameplay.

This behavior suggests that common infected are managed differently by the Source engine compared to special infected or player entities, and are likely stored or referenced outside the standard entity list currently being iterated.

Apart from this reminder, there are several areas that can be improved or extended. The current testing was performed on a relatively small sample size, in scenarios where large groups of zombies attack simultaneously, the aimbot can become unreliable due to rapid target switching and the lack of prioritization logic.

Additionally, because common infected vary in height and animation state, the current aiming logic does not always align correctly with the head position. This can be observed in the demo, where the aimbot occasionally aims slightly above or below the intended target.

Feedback, corrections, or alternative approaches are always welcome/feel free to reach out by email. If you found this write up useful, I’d love to hear that as well.