> ## Documentation Index
> Fetch the complete documentation index at: https://docs.spurdoverse.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a Full ESP

> Step-by-step tutorial: build a polished ESP overlay with bounding boxes, health bars, name tags, and visibility checks.

In this tutorial we'll build a complete ESP from scratch.
By the end you'll have animated corner boxes,
health bars with damage indicators, name tags, and visibility-aware
coloring - all running in real-time.

<Frame caption="What we're building: corner box ESP with health bars and name tags.">
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/spurdo/images/esp-final.png" alt="Final ESP result" />
</Frame>

## What you'll learn

<Steps>
  <Step title="Project setup">
    Import modules and set up the render loop.
  </Step>

  <Step title="Bounding box">
    Calculate screen-space bounding boxes from collision data.
  </Step>

  <Step title="Box rendering">
    Draw outlined boxes with corner accents.
  </Step>

  <Step title="Health bars">
    Animated health bars with gradient colors and damage indicators.
  </Step>

  <Step title="Name tags & visibility">
    Player names with text shadows and visibility-based coloring.
  </Step>
</Steps>

***

## Step 1 - Project setup

Every ESP starts the same way: import what you need, hook into the
render event, and grab the local player.

```ts theme={null}
import Entities from "@native/entities";
import Render   from "@native/render";
import Trace    from "@native/trace";
import Game     from "@native/game";

on("render", () => {
    const local = Entities.getLocalPlayer();
    if (!local) return;

    for (const enemy of Entities.getPlayers({ skipLocal: true })) {
        if (enemy.m_iHealth <= 0) continue;

        // we'll draw here
    }
});
```

<Note>
  `getPlayers({ skipLocal: true })` returns all player pawns except
  your own. We skip dead players early with `m_iHealth <= 0`.
</Note>

***

## Step 2 - Bounding box calculation

The naïve approach is to hardcode a height offset (`origin.z + 72`),
but that breaks for crouching players and non-standard models.
Instead, we use the entity's **collision bounds** - `m_pCollision`
gives us the real `mins`/`maxs` of the hitbox.

We project all 8 corners of the 3D box to screen space and find
the tightest 2D rectangle that contains them:

```ts theme={null}
function getBoundingBox(entity) {
    const origin = entity.m_pGameSceneNode.m_vecAbsOrigin;
    const collision = entity.m_pCollision;
    if (!collision) return null;

    const mins = collision.m_vecMins;
    const maxs = collision.m_vecMaxs;

    let screenMins = new Vector2(Infinity, Infinity);
    let screenMaxs = new Vector2(-Infinity, -Infinity);

    for (let i = 0; i < 8; i++) {
        const world = new Vector(
            origin.x + (i & 1 ? maxs.x : mins.x),
            origin.y + (i & 2 ? maxs.y : mins.y),
            origin.z + (i & 4 ? maxs.z : mins.z),
        );
        const { success, screen } = Math.worldToScreen(world);
        if (!success) return null;

        screenMins.x = Math.min(screenMins.x, screen.x);
        screenMins.y = Math.min(screenMins.y, screen.y);
        screenMaxs.x = Math.max(screenMaxs.x, screen.x);
        screenMaxs.y = Math.max(screenMaxs.y, screen.y);
    }

    return { mins: screenMins, maxs: screenMaxs };
}
```

<Tip>
  The bitmask trick `i & 1`, `i & 2`, `i & 4` generates all 8
  combinations of min/max for x, y, z - one for each corner of the box.
</Tip>

If **any** corner is behind the camera, `worldToScreen` returns
`success: false` and we bail out - drawing a partial box looks worse
than drawing nothing.

Let's plug it in:

```ts theme={null}
on("render", () => {
    const local = Entities.getLocalPlayer();
    if (!local) return;

    for (const enemy of Entities.getPlayers({ skipLocal: true })) {
        if (enemy.m_iHealth <= 0) continue;

        const box = getBoundingBox(enemy);
        if (!box) continue;

        // next step: draw it
    }
});
```

***

## Step 3 - Box rendering

A single `Render.rect` looks flat. For a polished look we'll draw
three layers - dark outer outline, colored main box, and dark inner
outline - plus animated corner accents.

### Basic triple-outline box

```ts theme={null}
// outer shadow
Render.rect(
    new Vector2(box.mins.x - 1, box.mins.y - 1),
    new Vector2(box.maxs.x + 1, box.maxs.y + 1),
    new Color(0, 0, 0, 180), 1
);

// main box
Render.rect(box.mins, box.maxs, Color.red(), 1);

// inner shadow
Render.rect(
    new Vector2(box.mins.x + 1, box.mins.y + 1),
    new Vector2(box.maxs.x - 1, box.maxs.y - 1),
    new Color(0, 0, 0, 100), 1
);
```

This alone already looks much better than a single rectangle.
The dark outlines create depth and make the box readable against
any background.

### Corner accents

Corner-style ESP draws short lines at each corner instead of a
full rectangle. We'll animate their length with a sine wave for
a subtle pulsing effect:

```ts theme={null}
const w = box.maxs.x - box.mins.x;
const pulse = (Math.sin(Game.curTime * 3 + enemy.index) + 1) / 2;
const cornerLen = Math.clamp(w * 0.25 + pulse * 4, 8, w * 0.4);
const accent = Color.red().withAlpha(255);

// top-left
Render.line(box.mins, new Vector2(box.mins.x + cornerLen, box.mins.y), accent, 2);
Render.line(box.mins, new Vector2(box.mins.x, box.mins.y + cornerLen), accent, 2);

// top-right
Render.line(
    new Vector2(box.maxs.x, box.mins.y),
    new Vector2(box.maxs.x - cornerLen, box.mins.y), accent, 2
);
Render.line(
    new Vector2(box.maxs.x, box.mins.y),
    new Vector2(box.maxs.x, box.mins.y + cornerLen), accent, 2
);

// bottom-left
Render.line(
    new Vector2(box.mins.x, box.maxs.y),
    new Vector2(box.mins.x + cornerLen, box.maxs.y), accent, 2
);
Render.line(
    new Vector2(box.mins.x, box.maxs.y),
    new Vector2(box.mins.x, box.maxs.y - cornerLen), accent, 2
);

// bottom-right
Render.line(box.maxs, new Vector2(box.maxs.x - cornerLen, box.maxs.y), accent, 2);
Render.line(box.maxs, new Vector2(box.maxs.x, box.maxs.y - cornerLen), accent, 2);
```

<Tip>
  Adding `enemy.index` to the sine offset makes each player's corners
  pulse independently - otherwise all boxes breathe in sync which
  looks unnatural.
</Tip>

***

## Step 4 - Health bars

A health bar sits to the left of the bounding box. We'll add:

* A dark background with outline
* A color-gradient fill (green → red)
* Smooth animation when health changes
* A white "damage indicator" showing where health used to be

### Per-player state

To animate smoothly, we need to remember each player's previous
health value between frames:

```ts theme={null}
const playerState = {};

function getState(index) {
    if (!playerState[index]) {
        playerState[index] = { hpSmooth: 1 };
    }
    return playerState[index];
}
```

### Drawing the bar

```ts theme={null}
const state = getState(enemy.index);
const hp = Math.clamp(enemy.m_iHealth / 100, 0, 1);
const h = box.maxs.y - box.mins.y;

// smoothly animate toward actual HP
state.hpSmooth = Math.lerp(state.hpSmooth, hp, Game.frameTime * 10);

const barW = 4;
const barGap = 3;
const barX = box.mins.x - barW - barGap;

// background
Render.rectFilled(
    new Vector2(barX - 1, box.mins.y - 1),
    new Vector2(barX + barW + 1, box.maxs.y + 1),
    new Color(0, 0, 0, 200)
);

// health fill (bottom to top)
const fillH = h * state.hpSmooth;
const hpColor = Color.green().lerp(Color.red(), 1 - state.hpSmooth);

Render.rectFilled(
    new Vector2(barX, box.maxs.y - fillH),
    new Vector2(barX + barW, box.maxs.y),
    hpColor
);

// damage indicator - white flash showing lost HP
if (state.hpSmooth > hp + 0.01) {
    const dmgH = h * state.hpSmooth;
    Render.rectFilled(
        new Vector2(barX, box.maxs.y - dmgH),
        new Vector2(barX + barW, box.maxs.y - h * hp),
        new Color(255, 255, 255, 150)
    );
}

// HP number (only when damaged)
if (hp < 1) {
    Render.text(
        new Vector2(barX - 2, box.maxs.y - fillH - 12),
        `${enemy.m_iHealth}`,
        Color.white()
    );
}
```

The `Math.lerp` with `frameTime * 10` creates an exponential decay
animation - the bar moves fast at first then slows down as it
approaches the target value.

***

## Step 5 - Name tags & visibility

### Eye position helper

For accurate visibility checks we need the player's **eye position**,
not just their feet. The view offset is stored as a
`CNetworkViewOffsetVector` - a packed struct that comes back as raw
bytes. We read the floats manually:

```ts theme={null}
function getEyePos(player) {
    const origin = player.m_pGameSceneNode.m_vecAbsOrigin;
    const raw = player.m_vecViewOffset;
    const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);

    return new Vector(
        origin.x + dv.getFloat32(0x10, true),
        origin.y + dv.getFloat32(0x18, true),
        origin.z + dv.getFloat32(0x20, true),
    );
}
```

<Note>
  `m_vecViewOffset` is a `CNetworkViewOffsetVector` with 16 bytes of
  padding, then three `CNetworkedQuantizedFloat` structs at offsets
  `0x10`, `0x18`, `0x20`. Each starts with a `float` we can read
  directly.
</Note>

### Visibility-aware box color

Instead of a static red box, we'll smoothly transition between green
(visible) and red (hidden):

```ts theme={null}
// add visAlpha to player state
function getState(index) {
    if (!playerState[index]) {
        playerState[index] = { hpSmooth: 1, visAlpha: 0 };
    }
    return playerState[index];
}

// in the render loop:
const eyePos = getEyePos(local);
const enemyPos = enemy.m_pGameSceneNode.m_vecAbsOrigin;

const visible = Trace.isVisible(eyePos, enemyPos);
state.visAlpha = Math.lerp(state.visAlpha, visible ? 1 : 0, Game.frameTime * 8);

const boxColor = new Color(100, 255, 100).lerp(
    new Color(255, 80, 80),
    1 - state.visAlpha
);
```

Now replace the hardcoded `Color.red()` in the box and corner
accent drawing with `boxColor`.

### Name tags with shadow

Use `Render.calcTextSize` to measure the text and center it above
the box. A text shadow (same text drawn 1px offset in black) makes
names readable against any background:

```ts theme={null}
const name = enemy.controller?.m_iszPlayerName ?? "unknown";
const textSize = Render.calcTextSize(name);
const centerX = (box.mins.x + box.maxs.x) / 2 - textSize.x / 2;

// shadow (offset by 1px)
Render.text(new Vector2(centerX + 1, box.mins.y - 15), name, new Color(0, 0, 0, 200));
// main text
Render.text(new Vector2(centerX, box.mins.y - 16), name, Color.white());
```

***

## Final result

Here's everything put together - a complete, working ESP script:

```ts theme={null}
import Entities from "@native/entities";
import Render   from "@native/render";
import Trace    from "@native/trace";
import Game     from "@native/game";

// helpers

// returns screen-space bounding box from entity's collision bounds,
// or null if any corner is behind the camera
function getBoundingBox(entity) {
    const origin = entity.m_pGameSceneNode.m_vecAbsOrigin;
    const collision = entity.m_pCollision;
    if (!collision) return null;

    const mins = collision.m_vecMins;
    const maxs = collision.m_vecMaxs;

    // we'll find the tightest screen-space rectangle
    let screenMins = new Vector2(Infinity, Infinity);
    let screenMaxs = new Vector2(-Infinity, -Infinity);

    // project all 8 corners of the 3D box
    for (let i = 0; i < 8; i++) {
        const world = new Vector(
            origin.x + (i & 1 ? maxs.x : mins.x),
            origin.y + (i & 2 ? maxs.y : mins.y),
            origin.z + (i & 4 ? maxs.z : mins.z),
        );
        const { success, screen } = Math.worldToScreen(world);
        if (!success) return null; // corner behind camera, skip entirely

        screenMins.x = Math.min(screenMins.x, screen.x);
        screenMins.y = Math.min(screenMins.y, screen.y);
        screenMaxs.x = Math.max(screenMaxs.x, screen.x);
        screenMaxs.y = Math.max(screenMaxs.y, screen.y);
    }

    return { mins: screenMins, maxs: screenMaxs };
}

// reads the eye position from the player's view offset
// m_vecViewOffset is a CNetworkViewOffsetVector (raw bytes),
// floats sit at offsets 0x10, 0x18, 0x20
function getEyePos(player) {
    const origin = player.m_pGameSceneNode.m_vecAbsOrigin;
    const raw = player.m_vecViewOffset;
    const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);

    return new Vector(
        origin.x + dv.getFloat32(0x10, true),
        origin.y + dv.getFloat32(0x18, true),
        origin.z + dv.getFloat32(0x20, true),
    );
}

// store per-player animation state so transitions persist between frames
const playerState = {};

function getState(index) {
    if (!playerState[index]) {
        playerState[index] = { visAlpha: 0, hpSmooth: 1 };
    }
    return playerState[index];
}

on("render", () => {
    const local = Entities.getLocalPlayer();
    if (!local) return;

    const eyePos = getEyePos(local); // our eye position for visibility checks
    const dt = Game.frameTime;        // delta time for smooth animations

    for (const enemy of Entities.getPlayers({ skipLocal: true })) {
        if (enemy.m_iHealth <= 0) continue;

        const box = getBoundingBox(enemy);
        if (!box) continue;

        const state = getState(enemy.index);
        const enemyPos = enemy.m_pGameSceneNode.m_vecAbsOrigin;

        // smooth visibility transition (0 = hidden, 1 = visible)
        const visible = Trace.isVisible(eyePos, enemyPos);
        state.visAlpha = Math.lerp(state.visAlpha, visible ? 1 : 0, dt * 8);

        // smooth health animation (lerp toward real HP)
        const hp = Math.clamp(enemy.m_iHealth / 100, 0, 1);
        state.hpSmooth = Math.lerp(state.hpSmooth, hp, dt * 10);

        const w = box.maxs.x - box.mins.x;
        const h = box.maxs.y - box.mins.y;

        // green when visible, red when hidden, smoothly interpolated
        const boxColor = new Color(100, 255, 100).lerp(
            new Color(255, 80, 80), 1 - state.visAlpha
        );

        // subtle sine pulse, offset per player so they don't all sync
        const pulse = (Math.sin(Game.curTime * 3 + enemy.index) + 1) / 2;

        // box: three layers for depth (outer dark, main color, inner dark)
        Render.rect(
            new Vector2(box.mins.x - 1, box.mins.y - 1),
            new Vector2(box.maxs.x + 1, box.maxs.y + 1),
            new Color(0, 0, 0, 180), 1
        );
        Render.rect(box.mins, box.maxs, boxColor.withAlpha(200), 1);
        Render.rect(
            new Vector2(box.mins.x + 1, box.mins.y + 1),
            new Vector2(box.maxs.x - 1, box.maxs.y - 1),
            new Color(0, 0, 0, 100), 1
        );

        // animated corner accents (thicker lines at each corner)
        const cornerLen = Math.clamp(w * 0.25 + pulse * 4, 8, w * 0.4);
        const accent = boxColor.withAlpha(255);

        // top-left corner
        Render.line(box.mins, new Vector2(box.mins.x + cornerLen, box.mins.y), accent, 2);
        Render.line(box.mins, new Vector2(box.mins.x, box.mins.y + cornerLen), accent, 2);
        // top-right corner
        Render.line(new Vector2(box.maxs.x, box.mins.y), new Vector2(box.maxs.x - cornerLen, box.mins.y), accent, 2);
        Render.line(new Vector2(box.maxs.x, box.mins.y), new Vector2(box.maxs.x, box.mins.y + cornerLen), accent, 2);
        // bottom-left corner
        Render.line(new Vector2(box.mins.x, box.maxs.y), new Vector2(box.mins.x + cornerLen, box.maxs.y), accent, 2);
        Render.line(new Vector2(box.mins.x, box.maxs.y), new Vector2(box.mins.x, box.maxs.y - cornerLen), accent, 2);
        // bottom-right corner
        Render.line(box.maxs, new Vector2(box.maxs.x - cornerLen, box.maxs.y), accent, 2);
        Render.line(box.maxs, new Vector2(box.maxs.x, box.maxs.y - cornerLen), accent, 2);

        // health bar on the left side of the box
        const barW = 4;
        const barGap = 3;
        const barX = box.mins.x - barW - barGap;

        // dark background with 1px padding
        Render.rectFilled(
            new Vector2(barX - 1, box.mins.y - 1),
            new Vector2(barX + barW + 1, box.maxs.y + 1),
            new Color(0, 0, 0, 200)
        );

        // fill from bottom to top, color goes green -> red as HP drops
        const fillH = h * state.hpSmooth;
        const hpColor = Color.green().lerp(Color.red(), 1 - state.hpSmooth);

        Render.rectFilled(
            new Vector2(barX, box.maxs.y - fillH),
            new Vector2(barX + barW, box.maxs.y),
            hpColor
        );

        // white damage indicator: shows where HP was before it dropped
        if (state.hpSmooth > hp + 0.01) {
            const dmgH = h * state.hpSmooth;
            Render.rectFilled(
                new Vector2(barX, box.maxs.y - dmgH),
                new Vector2(barX + barW, box.maxs.y - h * hp),
                new Color(255, 255, 255, 150)
            );
        }

        // show HP number next to the bar when damaged
        if (hp < 1) {
            Render.text(
                new Vector2(barX - 2, box.maxs.y - fillH - 12),
                `${enemy.m_iHealth}`,
                Color.white()
            );
        }

        // name tag centered above the box, with a dark shadow for readability
        const name = enemy.controller?.m_iszPlayerName ?? "unknown";
        const textSize = Render.calcTextSize(name);
        const centerX = (box.mins.x + box.maxs.x) / 2 - textSize.x / 2;

        Render.text(new Vector2(centerX + 1, box.mins.y - 15), name, new Color(0, 0, 0, 200)); // shadow
        Render.text(new Vector2(centerX, box.mins.y - 16), name, Color.white());
    }
});

export function onUnload() {
    for (const key in playerState) delete playerState[key];
}
```

***

## Next steps

Now that you have a working ESP, try extending it:

* **Weapon name** - read `enemy.m_hActiveWeapon` and display the weapon class below the box
* **Distance** - show distance in meters between you and each player
* **Snaplines** - draw lines from the bottom of your screen to each enemy
* **Team filtering** - compare `m_iTeamNum` to only draw enemies, not teammates
