Chimera Scripting: A guide to making your own client mods

This guide walks through the core scripting model and practical examples so you can build your own HUDs, gameplay tweaks, and quality-of-life mods using Chimera’s event-driven Lua environment.


The Basics: What Every Script Needs

Every Chimera script starts with a version check and a few callbacks. Here’s a skeleton script:

clua_version = 2.056 -- declare the Chimera Lua API version this script targets

set_callback("command","OnCommand")
set_callback("frame","OnFrame")
set_callback("preframe","OnPreFrame")
set_callback("map load","OnMapLoad")
set_callback("map_preload","OnMapPreload")
set_callback("precamera","OnPreCamera")
set_callback("rcon_message","OnRconMessage")
set_callback("spawn","OnSpawn")
set_callback("prespawn","OnPreSpawn")
set_callback("tick","OnTick")
set_callback("pretick","OnPreTick")
set_callback("unload","OnScriptUnload")

function OnPreTick()
  -- called just before the main tick update
end

function OnTick()
    -- called ~30 times per second (main gameplay update loop)
end

function OnPreFrame()
    -- called before rendering each frame
end

function OnFrame()
    -- called after preframe, once per rendered frame
end

function OnPreCamera()
    -- called right before camera calculations are applied
end

function OnCommand(cmd)
    -- called when you type a command in the RCON console (press ~ key)
end

function OnRconMessage(msg)
    -- called when an incoming RCON message is received; return false to block it
end

function OnPreSpawn(player)
    -- called before a player spawns
end

function OnSpawn(player)
    -- called when a player finishes spawning
end

function OnMapPreload(map_name)
    -- called before a map fully loads (early initialization point)
end

function OnMapLoad()
    -- called after the map has fully loaded (reset or init gameplay state here)
end

function OnScriptUnload()
    -- called when the script is unloaded
end

Reading Player Data

Everything interesting comes from reading memory offsets.

Get the player object

-- Optionally pass a player ID (0-15) to either function:
local dynamic_player = get_dynamic_player(id)
local static_player = get_player(id)

get_dynamic_player() returns a pointer to the player’s current object. Use this for position, velocity, health, shields, and other object data. Returns nil if the player is dead.

get_player() returns a pointer to the player’s persistent player data structure. Use this for player-related data that exists independently of the live object (team, kills, deaths, ping, name).

Read a float (position, health, shields)

local x = read_float(dynamic_player + 0x5C)
local y = read_float(dynamic_player + 0x60)
local z = read_float(dynamic_player + 0x64)

Health and shields are also floats (0.0 = empty, 1.0 = full). To show as a percentage:

local health_raw = read_float(dynamic_player + 0xE0)  -- 0..1
local shields_raw = read_float(dynamic_player + 0xE4) -- 0..1

local health_percent = math.floor(health_raw * 100)
local shields_percent = math.floor(shields_raw * 100)

Read integers (team, ping, kills, deaths)

Use read_byte() for small values (0-255), read_word() for 16‑bit, and read_dword() for 32‑bit.

Team (0 = Red, 1 = Blue, from the player structure):

local team = read_byte(static_player + 0x20)
local team_name = (team == 0) and "Red" or "Blue"

Ping (milliseconds, from player structure):

local ping = read_dword(static_player + 0xDC)

Kills and deaths (from player structure):

local kills = read_word(static_player + 0x9C)
local deaths = read_word(static_player + 0xAE)

Read velocity vector (dynamic player)

Velocity is stored as three floats (world units per tick). Combine with a scaling factor to get km/h or mph.

local vx = read_float(dynamic_player + 0x68)
local vy = read_float(dynamic_player + 0x6C)
local vz = read_float(dynamic_player + 0x70)
local speed = math.sqrt(vx*vx + vy*vy + vz*vz)
local kmh = speed * 30 * 3.6   -- 30 ticks/sec, convert world units to km/h

Check if player is in a vehicle

Read the vehicle object ID from the dynamic player. If it’s 0xFFFFFFFF, the player is on foot.

local vehicle_id = read_dword(dynamic_player + 0x11C)
if vehicle_id ~= 0xFFFFFFFF then
    local vehicle = get_object(vehicle_id)
    -- now you can read vehicle position, health, type, etc.
end

Read current weapon ammo (dynamic player weapon)

First get the weapon object pointer from the dynamic player, then read ammo offsets.

local weapon_id = read_dword(dynamic_player + 0x118)
local weapon_object = get_object(weapon_id)
if weapon_object then
    local clip = read_word(weapon + 0x2B6)      -- rounds in magazine
    local reserve = read_word(weapon + 0x2B8)   -- total reserve ammo
end

Get player name

Player names are stored as a wide-character string (2 bytes per character, UTF-16). Because Halo names are limited to ASCII characters, each character’s high byte is 0x00. So you can read every other byte (the low bytes) and stop at a single 0x00 byte—that marks the null terminator.

local function get_player_name(id)
    local obj = get_player(id)
    local addr = obj + 0x4
    local bytes = {}
    for i = 1, 12 do
        local b = read_byte(addr + (i-1)*2)
        if b == 0 then break end
        bytes[#bytes+1] = string.char(b)
    end
    return table.concat(bytes)
end

Get object name (e.g. weapon name)

Gets the display name of an object (e.g. weapon, vehicle, equipment) by resolving its tag and extracting the tag path. The function reads the object’s tag ID, converts it to a tag pointer, reads the tag path string, and then strips the directory path to return only the object name (e.g. “weapons\assault_rifle” → “assault_rifle”). Returns “N/A” if the object is invalid, “???” if the tag cannot be resolved, or the raw path if parsing fails.

local function get_object_name(obj)
if not obj then return "N/A" end

    local tag = get_tag(read_dword(obj))
    if not tag then return "???" end
	
    local path = read_string(read_dword(tag + 0x10)) or "unknown"
    return path:match(".*\\([^\\]+)$") or path
end

local weapon_id = get_object(read_dword(dynamic_player + 0x118))
local weapon_name = get_object_name(weapon_id) -- returns "Weapon Name" (e.g. "Assault Rifle")

Showing Stuff on Screen: hud_message(), console_out()

The simplest way to talk to the player is hud_message("your text") or console_out("your text"). But if you call it every tick, you may fill the screen with garbage. Clear old messages first:

execute_script("cls")

Many scripts will benefit from an update interval to avoid spam. Here’s a typical pattern from

local timer = 0
local interval = 15 -- ticks between updates

function OnTick()
    timer = timer + 1
    if timer < interval then return end
    timer = 0

    -- read data and update HUD
    execute_script("cls")
    hud_message("Speed: " .. kmh .. " km/h")
end

Handling Commands: Toggle Your Feature

Need a way to turn a feature on/off? Do something like this:

local enabled = true
local command_name = "mything"

function OnCommand(cmd)
    if cmd:lower() == command_name then
        enabled = not enabled
        console_out("My thing " .. (enabled and "enabled" or "disabled"))
        return false  -- prevents Halo error message
    end
end

function OnTick()
    if not enabled then return end
    -- do your thing
end

Want arguments? Parse them:

local args = {}
for word in cmd:gmatch("%S+") do
    table.insert(args, word)
end

if args[1] == "mything" and args[2] then
    local value = tonumber(args[2])
    -- do something with value
end

-- Example: mything 100

Save persistent data example

local SAVE_FILE = "my_data.txt" -- file will appear in Halo's root installation folder

local function save_stats(stats)
    local f = io.open(SAVE_FILE, "w")
    if f then
        f:write(string.format("%d;%d;%d", stats.kills, stats.deaths))
        f:close()
    end
end

local function load_stats()
    local f = io.open(SAVE_FILE, "r")
    if not f then return {kills=0, deaths=0, credits=0} end
    local content = f:read("*l")
    f:close()
    local kills, deaths = content:match("(%d+);(%d+)")
    return {kills=tonumber(kills), deaths=tonumber(deaths)}
end

Script Management & Advanced Features

Now that you know how to write scripts, here’s where to put them and how to get the most out of Chimera’s scripting system.

Where to Put Your Scripts

Chimera looks for Lua scripts in two specific places, and treats them differently depending on where they live:

  • Global Scripts: Drop your .lua files in the main chimera\lua\scripts\global folder. These load when Chimera starts up and stay active until you manually reload them. Great for tools or HUD mods you use all the time.

  • Map Scripts: Put scripts into chimera\lua\scripts\maps. These only load when their associated map is active and unload automatically when you leave. Perfect for map-specific features.

By default, the chimera data folder will be located in C:\Users\<user>\Documents\My Games\Halo CE\chimera.

To reload all scripts without restarting the game, type chimera_lua_scripts_reload in console (press ~).

Version Check Requirement

We already mentioned clua_version = 2.056 at the top of every script. This tells Chimera which version of the Lua API your script expects. It prevents compatibility issues if the API changes in future versions. Always include it.

Embedded Scripts (Advanced)

Mapmakers can actually bake Lua scripts directly into their .map files using tools like Harbinger. Chimera can load these embedded scripts if you enable load_embedded_lua = 1 in your chimera.ini. The script then runs automatically when players load that map - no separate file needed. This is great for distributing map-specific mods without extra install steps.

Hotkeys: Running Scripts with Keystrokes

Chimera lets you bind any command - including your custom script commands - to keyboard keys through the chimera.ini file. The hotkeys section lets you execute:

  • Chimera’s native commands
  • Halo’s built-in console commands
  • Your custom script commands (like /wpninfo or /compass)

For example, you could bind F5 to /compass so players can toggle the compass without typing. Set this up in chimera.ini and your scripts can respond to single key presses instead of forcing players to type chat commands.