Skip to content
Damian Small

Building Nametags for Halo PC with Chimera

One of the differences between retail Halo PC and Halo Custom Edition is that Custom Edition includes teammate nametags, displayed by pressing F3.

Custom Edition was originally released in 2004 as a free, stand-alone version of Halo PC for existing owners. Its primary purpose was to support community-created maps built with the accompanying Halo Editing Kit. Although it shared most of the retail game's foundation, it also included a number of engine and interface changes that never made their way back into the original Halo PC game.

That left retail players without several Custom Edition features, including nametags.

Recreating that feature with Chimera initially seemed fairly straightforward: find each teammate's position, convert it into screen coordinates, and draw their name above them.

As it turned out, drawing the text on screen was the easy part. Figuring out where that text should appear was considerably harder.

The goal

The objective was simple enough:

  • Display nametags above teammates.

  • Correctly support vehicles.

  • Support widescreen displays.

  • Follow crouching, animations, and movement.

  • Avoid noticeable performance overhead.

  • Keep everything client-side.

The project ultimately became much larger than a single lua script. Alongside the nametag renderer itself, I had to build a reusable debugging framework that could dump game state into JSON files, capture screenshots, and provide calibration overlays for testing.

That tooling ended up being as valuable as the mod itself.

The first surprise: Chimera's isolated Lua state

My original plan was to split the project into several reusable components:

  • nametags.lua

  • debug_core.lua

  • tagcal.lua

Unfortunately, Chimera had other ideas.

Although every script in chimera\lua\scripts\global\ appears to share the same environment, each script actually runs inside its own isolated Lua state. Global variables defined in one script are completely invisible to every other script.

That meant this seemingly sensible approach of:

DebugCore = {}

-- Somewhere else ...

DebugCore.dump()

simply didn't work.

Instead, the debugging tools became callback-free snippets that could be placed into the main script whenever they were needed.

That discovery alone cost a few hours.

The second surprise: documentation was wrong

According to community documentation I found [1], draw_text() looked something like this:

draw_text(text, left, top, right, bottom, ...)

That wasn't true.

After creating a calibration overlay, dumping coordinates into JSON files, then comparing them against screenshots, I eventually discovered that the real function signature was:

draw_text(text, x, y, width, height, ...)

The difference seems subtle, but it completely changes how positioning text works.

Suppose you wanted to center text at x=320:

local width = 160

draw_text(
    text,
    320 - width / 2,
    y,
    width,
    16,
    "smaller",
    "center"
)

Using the old interpretation caused text to drift, and occasionally disappear entirely.

Nothing crashed, which made the problem even harder to identify.

Halo, Chimera, and the 4:3 problem

Halo was originally designed around a 4:3 aspect-ratio.

Chimera's widescreen fix modernises this by expanding the interface to fit newer displays without stretching, but this introduced an interesting complication: the coordinate system used for rendering is no longer identical to the coordinate system used internally by the game.

My first assumption was that the screen center could be derived directly from the current resolution:

half_width = screen_width * 0.375

That happened to work on a standard 16:9 display.

Unfortunately, it also happened to be wrong.

After digging through Chimera's source code [2] and verifying the results with a calibration overlay, I discovered that the horizontal center remains fixed at 320 when the widescreen fix is enabled, regardless of the monitor's aspect ratio.

The result was that 16:9 displays appeared correct while 16:10 displays slowly drifted out of alignment.

The fix was quite simple once the actual cause was understood:

local half_width = 320

Getting to that point was a lot less simple.

Converting world space into screen coordinates

The next challenge was projecting the three-dimensional point into two-dimensional screen space.

The camera state is exposed through Chimera's precamera callback:

function OnPreCamera(x, y, z, fov, ox1, oy1, oz1, ox2, oy2, oz2)
    return x, y, z, fov, ox1, oy1, oz1, ox2, oy2, oz2
end

From there, I could calculate the camera basis vectors and project world positions into screen coordinates.

Of course, that wasn't the end of the story.

The mysterious one-frame lag

For quite a while, nametags appeared to lag slightly behind the camera.

Player movement was perfectly smooth, yet rotating the camera produced a slight delay.

Eventually I added event logging and discovered that Chimera executes callbacks in the following order:

preframe
precamera

My script was capturing the camera in precamera and then rendering the nametags in preframe.

In other words, every frame was using the previous frame's camera position.

Moving the entire rendering process into precamera fixed the issue immediately.

Standing players, seated players, and skeletons

Finding the correct position for a standing player wasn't too difficult.

The real challenge came when players entered vehicles.

At first I assumed the normal position field could be reused:

read_float(dynamic_player + 0x5C)

Unfortunately, the value turned out to be relative to the seat itself rather than the absolute world-space coordinate.

The solution was eventually found elsewhere in memory:

dynamic_player + 0xA0

That gave me the player's true world position.

Later, while investigating the head positioning, I discovered an entire skeletal node array attached to the player object.

The head itself turned out to be node twelve:

head_x = read_float(dynamic_player + 0x7E8)
head_y = read_float(dynamic_player + 0x7EC)
head_z = read_float(dynamic_player + 0x7F0)

This was an great improvement because it meant nametags could follow player crouching, seated positions, and animations automatically.

Recreating the F3 toggle

The original version rendered teammate nametags at all times. The next step was to match the behaviour of Halo Custom Edition by using F3 to toggle the nametags on and off.

Chimera does not expose a general-purpose keyboard callback through its Lua API, so the implementation reads Halo’s keyboard-input state directly from memory:

local KEYBOARD_INPUT_ADDRESS = 0x006B1620
local F3_KEY_OFFSET = 0x03

The keyboard address and F3 offset were verified against the retail Halo PC executable used during development. The chat-state address was migrated from the older executable layout and still requires direct verification. All three values may differ between builds.

The keyboard state is stored as a table, with each key represented by an offset. Runtime probing confirmed that F3 is located at offset 0x03:

local f3_pressed =
    read_byte(KEYBOARD_INPUT_ADDRESS + F3_KEY_OFFSET) ~= 0

Because the input handler runs every frame, simply checking whether F3 is held would cause the nametags to toggle repeatedly during a single press.

The implementation therefore detects only the transition from released to pressed:

local f3_was_pressed = false

local function handle_nametag_keybind()
    local f3_pressed =
        read_byte(KEYBOARD_INPUT_ADDRESS + F3_KEY_OFFSET) ~= 0

    if f3_pressed and not f3_was_pressed then
        nametags_enabled = not nametags_enabled
    end

    f3_was_pressed = f3_pressed
end

This converts the held-key state into a single toggle event for each press.

The handler also ignores input while the chat box or console is open:

local CHAT_STATE_ADDRESS = 0x006B3858

local function input_is_blocked()
    local chat_is_open =
        read_byte(CHAT_STATE_ADDRESS) ~= 0

    return chat_is_open or console_is_open()
end

The completed input handler becomes:

local function handle_nametag_keybind()
    local f3_pressed =
        read_byte(KEYBOARD_INPUT_ADDRESS + F3_KEY_OFFSET) ~= 0

    if
        not input_is_blocked()
        and f3_pressed
        and not f3_was_pressed
    then
        nametags_enabled = not nametags_enabled
        save_nametag_status(nametags_enabled)
    end

    f3_was_pressed = f3_pressed
end

Chimera’s own hotkey implementation reads the keyboard state during preframe, so the nametag handler uses the same callback:

function OnPreFrame()
    handle_nametag_keybind()
end

set_callback("preframe", "OnPreFrame")

The selected state is also saved whenever F3 is pressed:

local NAMETAG_STATUS_FILE = "enabled.txt"

local function load_nametag_status()
    local saved_status = read_file(NAMETAG_STATUS_FILE)

    if saved_status then
        saved_status = saved_status:match("^%s*(.-)%s*$")

        if saved_status == "1" then return true end
        if saved_status == "0" then return false end
    end

    return true
end

local function save_nametag_status(enabled)
    write_file(
        NAMETAG_STATUS_FILE,
        enabled and "1" or "0"
    )
end

local nametags_enabled = load_nametag_status()

Nametags are enabled by default when no saved preference exists. After that, the previous state is restored whenever Halo starts or the Chimera Lua scripts are reloaded.

The result behaves much more like the native Halo Custom Edition feature: press F3 once to display teammate nametags, then press it again to hide them.

Possible future improvements

Another possibility would be to extend the existing system to support enemy nametags.

Unlike teammate nametags, this would obviously expose information that players would not normally have access to, making it unsuitable for ordinary gameplay. However, it could still have some practical applications from an administrative perspective.

For example, server administrators could use it to identify players breaking rules, investigate reports of suspicious behaviour, verify the behaviour of scripts, or produce screenshots and recordings for moderation purposes.

Since the renderer already identifies the local player and determines the team affiliation of every visible player, extending the existing logic would be comparatively straightforward.

A natural progression might be to support several independent display modes:

  • Friendly players only.

  • Enemy players only.

  • All players.

Combined with the F3 toggle, this would allow the feature to remain hidden during normal gameplay while still being available whenever it is needed.

Whether this functionality ultimately becomes part of the project remains to be seen, but the underlying work has already been done.

Building better debugging tools

The most valuable part of the project wasn't the nametag system itself.

It was the tooling.

Every time something behaved unexpectedly, I dumped the current game state to a JSON file, captured a screenshot, and compared the two manually.

That process allowed me to answer questions such as:

  • Which callback fired first?

  • Where is the camera located?

  • Which coordinates are being projected?

  • Which offsets change while seated?

  • Is the problem in world space, screen space, or the rendering stage itself?

Eventually the debugging tools became an entire framework of their own.

Lessons learned

This project reinforced one of the most important lessons in reverse engineering:

Never trust a value simply because someone else documented it.

Every offset, every coordinate system, every callback, and every assumption should be treated as unverified until it has been tested against reality.

Over the course of this project I was misled by:

  • Incorrect documentation.

  • Incorrect assumptions.

  • Stale memory addresses.

  • One-frame timing errors.

  • Coordinate system mismatches.

  • Incorrectly identified memory fields.

  • More than one promising-looking red herring.

Fortunately, Halo is more than twenty years old.

That means there's still plenty left to discover.

References

  1. [1]
    Scripting with Chimera - Client-Side Lua — Chalwk

    An excellent community reference for Chimera’s Lua API. It was the starting point for much of this work, and the draw_text() parameter description was later updated from left, top, right, bottom to left, top, width, height based on the findings from this project.

  2. [2]
    Chimera Source Code — Snowy Mouse / Chimera contributors

    Used to verify the widescreen scaling, text anchoring, and internal 640×480 coordinate behaviour described in this article.