Halo PC/CE: SAPP Lua Snippets
If you’ve ever wanted to level up your server scripting with SAPP (the powerful Halo PC/CE server mod), you’re in the right place. This guide collects battle-tested Lua snippets that interact directly with Halo’s memory - giving you fine control over player positions, vehicles, objectives, chat messages, and more.
Why these snippets matter:
SAPP Lua scripts let you customize gameplay, enforce rules, build minigames, and manage your server. The functions below are ready to drop into your own scripts - just remember that direct memory access is powerful and requires careful testing.
Heads up: Many snippets read/write raw memory offsets. Always test in a safe environment first, and keep backups of your server files.
Let’s dive in.
broadcast_excluding_player
Sends a chat message to all connected players except one. Perfect for private alerts or avoiding spam to a specific user.
Parameters:
message(string) - The message to send.exclude_player(number) - Player index (1-16) to skip.
Code:
local function sendExclude(message, exclude_player)
for i = 1, 16 do
if player_present(i) and i ~= exclude_player then
say(i, message)
end
end
end
Tip: Use this to announce events like “Player X found the secret” without revealing it to X themselves.
check_if_player_in_vehicle
Quickly test if a player is riding in any vehicle.
Parameters:
dyn_player(number) - Dynamic player memory address.
Returns:
true if the player is in a vehicle, false otherwise.
Code:
local function inVehicle(dyn_player)
return read_dword(dyn_player + 0x11C) ~= 0xFFFFFFFF
end
Note: 0xFFFFFFFF means “no vehicle”. This check is lightning fast - great for per-tick logic.
check_if_player_is_in_range
Determines if two 3D points are within a given radius. Uses squared distance to avoid expensive square root calculations.
Parameters:
x1, y1, z1,x2, y2, z2(numbers) - Coordinates of the two points.radius(number) - Distance threshold.
Returns:
true if within radius, false otherwise.
Code:
local function inRange(x1, y1, z1, x2, y2, z2, radius)
local dx = x1 - x2
local dy = y1 - y2
local dz = z1 - z2
return (dx * dx + dy * dy + dz * dz) <= (radius * radius)
end
Why this works: Comparing squared distances avoids math.sqrt and is much faster in hot loops.
check_if_vehicle_occupied
Checks whether a specific vehicle object is currently occupied by any player.
Parameters:
vehicleObject(number) - Memory address of the vehicle.
Returns:
true if any player is inside, false otherwise.
Code:
local function isVehicleOccupied(vehicleObject)
for i = 1, 16 do
local dyn = get_dynamic_player(i)
if player_present(i) and player_alive(i) and dyn ~= 0 then
local vehicle_id = read_dword(dyn + 0x11C)
if vehicle_id == 0xFFFFFFFF then goto next_player end
local vehicle_obj = get_object_memory(vehicle_id)
if vehicle_obj ~= 0 and vehicle_obj == vehicleObject then
return true
end
::next_player::
end
end
return false
end
Use case: Prevent vehicle theft, trigger effects when a vehicle becomes empty, or enforce seat limits.
check_player_invisibility_state
Detects if a player is currently invisible (e.g., from active camo or a script effect).
Parameters:
id(number) - Player index (1-16).
Returns:
true if invisible, false otherwise.
Code:
local function isPlayerInvisible(id)
local dyn_player = get_dynamic_player(id)
local invisible = read_float(dyn_player + 0x37C)
return dyn_player ~= 0 and invisible == 1
end
Note: This reads a float that is exactly 1.0 when invisible. Works for both built-in camo and custom scripts.
clear_player_rcon_chat_buffer
Clears a player’s console/chat buffer by printing many blank lines. Great for hiding previous messages or resetting the display.
Parameters:
id(number) - Player index.
Code:
local function clearConsole(id)
for _ = 1, 25 do
rprint(id, " ")
end
end
Pro tip: 25 lines is usually enough to clear most screen heights. Adjust as needed.
custom_spawn_with_rotation
Writes position and rotation data directly to a dynamic object’s memory - ideal for custom spawn points or teleportation with facing direction.
Parameters:
dyn(number) - Dynamic player memory address.px, py, pz(numbers) - Spawn coordinates.pR(number) - Rotation in radians.z_off(number, optional) - Vertical offset. Defaults to0.3.
Code:
local function spawnObject(dyn, px, py, pz, pR, z_off)
z_off = z_off or 0.3
local x = px
local y = py
local z = pz + z_off
local r = pR
write_vector3d(dyn + 0x5C, x, y, z)
write_vector3d(dyn + 0x74, math.cos(r), math.sin(r), 0)
end
Explanation: The forward vector (offset 0x74) controls which direction the player faces. Using math.cos and math.sin converts radians to a unit vector.
deep_copy_table
Creates a deep copy of a table, including nested tables and metatables. Extremely useful when you need an independent duplicate of complex data.
Warning: This recurses into everything, including keys, values, and metatables. If your table has circular references (a table that contains itself), this will cause an infinite loop. Use only on acyclic data.
Parameters:
orig(any) - The value or table to copy.
Returns:
A fully independent copy.
Code:
local function deepCopy(orig)
if type(orig) ~= "table" then
return orig
end
local copy = {}
for key, value in pairs(orig) do
copy[deepCopy(key)] = deepCopy(value)
end
return setmetatable(copy, deepCopy(getmetatable(orig)))
end
find_tag_by_name_substring
Scans the tag table for a tag whose path or name contains a given substring. Optionally filter by tag class.
Parameters:
substring(string) - Text to search for (case-insensitive).class_filter(string or nil) - Optional, e.g.,'weap','vehi'. Passnilto search all classes.
Returns:
MetaIndex of the first matching tag, or nil if not found.
Code:
local function findTagByNameSubstring(substring, class_filter)
local base_tag_table = 0x40440000
substring = substring:lower()
local tag_array = read_dword(base_tag_table)
local tag_count = read_dword(base_tag_table + 0xC)
for i = 0, tag_count - 1 do
local tag = tag_array + 0x20 * i
local class = read_dword(tag)
if class_filter == nil or class == read_dword(lookup_tag(class_filter, "")) then
local name_ptr = read_dword(tag + 0x10)
if name_ptr ~= 0 then
local name = read_string(name_ptr)
if name and name:lower():find(substring, 1, true) then
return read_dword(tag + 0xC)
end
end
end
end
return nil
end
format_message_string
Two different approaches to formatting messages - choose the one that fits your style.
Version 1: Classic string.format style
Define templates as constants, then use formatMessage which behaves like string.format.
Templates:
local HELLO_MESSAGE = "Hello world!"
local PLAYER_JOINED = "Player %s has joined the game."
local PLAYER_SCORE = "%s scored %d points in %d minutes."
Function:
local function formatMessage(message, ...)
if select('#', ...) > 0 then
return message:format(...)
end
return message
end
Usage:
print(formatMessage(HELLO_MESSAGE)) -- Hello world!
print(formatMessage(PLAYER_JOINED, "Chalwk")) -- Player Chalwk has joined the game.
print(formatMessage(PLAYER_SCORE, "Chalwk", 150, 12))
Version 2: Placeholder-based (template variables)
Use named placeholders like $name and replace them from a table.
Templates:
local SCORE_MESSAGE = "$name scored $points points in $minutes minutes."
local JOIN_MESSAGE = "Player $name has joined the server."
local LEAVE_MESSAGE = "Player $name has left the server."
Function:
local function formatMessage(message, vars)
return (message:gsub("%$(%w+)", function(key)
return vars[key] or "$" .. key
end))
end
Usage:
print(formatMessage(JOIN_MESSAGE, {name = "Chalwk"}))
print(formatMessage(SCORE_MESSAGE, {name = "Chalwk", points = 150, minutes = 12}))
print(formatMessage(LEAVE_MESSAGE, {})) -- unmatched placeholders remain
Which one to use?
Version 1 is simpler for ordered arguments. Version 2 is more readable when you have many named values.
get_aim_vector
Retrieves the desired aim vector (camera direction) from a dynamic object.
Parameters:
dyn(number) - Dynamic object memory address.
Returns:
aim_x, aim_y, aim_z - the camera’s facing direction components.
Code:
local function getAimVector(dyn)
local aim_x = read_float(dyn + 0x230)
local aim_y = read_float(dyn + 0x234)
local aim_z = read_float(dyn + 0x238)
return aim_x, aim_y, aim_z
end
Use case: Create aim-dependent abilities, like “look at a player to freeze them”.
get_base_directory_and_config_path
Two helper functions to dynamically locate your Halo server folder and the SAPP config directory - no hardcoded paths needed.
Code:
local function getConfigPath()
return read_string(read_dword(sig_scan('68??????008D54245468') + 0x1))
end
local function getBaseDir(folder)
folder = folder or ""
local exe_path = read_string(read_dword(sig_scan('0000BE??????005657C605') + 0x3))
local base_path = exe_path:match("(.*\\)")
return base_path .. folder
end
Examples:
local base_dir = getBaseDir() -- "C:\YourHaloServer\"
local maps_dir = getBaseDir("maps") -- "C:\YourHaloServer\maps"
local sapp_cg = getConfigPath() -- "C:\YourHaloServer\cg\sapp"
Note: The signature scans are specific to SAPP. They should work on standard builds, but test after updates.
get_flag_object_meta_and_tag_name
Finds the flag (objective) tag on the current map and returns its meta ID and tag name.
Returns:
flag_meta_id (number), flag_tag_name (string) - or nil, nil if no flag exists.
Code:
local flag_meta_id, flag_tag_name
local base_tag_table = 0x40440000
local tag_entry_size = 0x20
local tag_data_offset = 0x14
local bit_check_offset = 0x308
local bit_index = 3
local function getFlagData()
local tag_array = read_dword(base_tag_table)
local tag_count = read_dword(base_tag_table + 0xC)
for i = 0, tag_count - 1 do
local tag = tag_array + tag_entry_size * i
local tag_class = read_dword(tag)
if tag_class == 0x77656170 then -- "weap"
local tag_data = read_dword(tag + tag_data_offset)
if read_bit(tag_data + bit_check_offset, bit_index) == 1 then
if read_byte(tag_data + 2) == 0 then
flag_meta_id = read_dword(tag + 0xC)
flag_tag_name = read_string(read_dword(tag + 0x10))
return flag_meta_id, flag_tag_name
end
end
end
end
return nil, nil
end
How it works:
- Looks for a weapon tag (
weap) with the “objective” bit set. - Byte
+2of the tag data equals0for flag (oddball is4).
get_object_tag_address
Retrieves the tag class and name of any object (vehicle, weapon, equipment, etc.). Includes a practical example for when a player enters a vehicle.
Code:
local function getTag(object)
local tag_class = read_byte(object + 0xB4)
local tag_address = read_word(object) * 32 + 0x40440038
local tag_name = read_string(read_dword(tag_address))
return tag_class, tag_name
end
Example - OnVehicleEnter:
function OnVehicleEnter(playerIndex)
local dyn = get_dynamic_player(playerIndex)
if dyn == 0 then return end
local vehicle_id = read_dword(dyn + 0x11C)
if vehicle_id == 0xFFFFFFFF then return end
local vehicle_obj = get_object_memory(vehicle_id)
if vehicle_obj == 0 then return end
local tag_class, tag_name = getTag(vehicle_obj)
print(tag_class, tag_name) -- e.g., "vehi", "vehicles\warthog\mp_warthog"
end
Memory explanation:
read_word(object)gives the object’s tag index.- Each tag entry is 32 bytes, so multiply by 32.
- Add the base tag table address (
0x40440038) to locate the tag’s metadata.
get_objective_flag_or_oddball
Checks if the player’s currently held weapon is an objective - flag, oddball, or either.
Parameters:
dyn_player(number) - Dynamic player address.objective_type(string, optional) -"oddball","flag", or"any"(default"any").
Returns:
true if holding the specified objective type.
Code:
local function hasObjective(dyn_player, objective_type)
local base_tag_table = 0x40440000
local tag_entry_size = 0x20
local tag_data_offset = 0x14
local bit_check_offset = 0x308
local bit_index = 3
objective_type = objective_type or "any"
local weapon_id = read_dword(dyn_player + 0x118)
local weapon_obj = get_object_memory(weapon_id)
if weapon_obj == nil or weapon_obj == 0 then return false end
local tag_address = read_word(weapon_obj)
local tag_data_base = read_dword(base_tag_table)
local tag_data = read_dword(tag_data_base + tag_address * tag_entry_size + tag_data_offset)
if read_bit(tag_data + bit_check_offset, bit_index) ~= 1 then return false end
local obj_byte = read_byte(tag_data + 2)
local is_oddball = (obj_byte == 4)
local is_flag = (obj_byte == 0)
if objective_type == "oddball" then
return is_oddball
elseif objective_type == "flag" then
return is_flag
else
return is_oddball or is_flag
end
end
Tip: Use this to prevent flag carriers from using weapons or to give oddball carriers special abilities.
get_player_inventory
Returns a table with details of all four weapon slots: ammo, clip, secondary ammo, heat (for energy weapons), and grenade counts.
Parameters:
dyn_player(number) - Dynamic player address.
Returns:
A table where each element corresponds to a weapon slot (1-4), containing:
id, ammo, clip, ammo2, clip2, heat, frags, plasmas.
Code:
local function getInventory(dyn_player)
local inventory = {}
for i = 0, 3 do
local weapon = read_dword(dyn_player + 0x2F8 + i * 4)
local object = get_object_memory(weapon)
if object ~= 0 then
inventory[i + 1] = {
id = read_dword(object),
ammo = read_word(object + 0x2B6),
clip = read_word(object + 0x2B8),
ammo2 = read_word(object + 0x2C6),
clip2 = read_word(object + 0x2C8),
heat = read_float(object + 0x240),
frags = read_byte(dyn_player + 0x31E),
plasmas = read_byte(dyn_player + 0x31F)
}
end
end
return inventory
end
Note: Empty slots are skipped (not included in the table). The order matches the player’s weapon selection.
get_player_world_coordinates
Retrieves the player’s actual world position, automatically adjusting for crouch height and vehicle offsets.
Parameters:
dyn_player(number) - Dynamic player address.
Returns:
x, y, z coordinates (adjusted).
Code:
local function getPlayerPosition(dyn_player)
local crouch = read_float(dyn_player + 0x50C)
local vehicle_id = read_dword(dyn_player + 0x11C)
local vehicle_obj = get_object_memory(vehicle_id)
local x, y, z
if vehicle_id == 0xFFFFFFFF then
x, y, z = read_vector3d(dyn_player + 0x5C)
elseif vehicle_obj ~= 0 then
x, y, z = read_vector3d(vehicle_obj + 0x5C)
end
local z_off = (crouch == 0) and 0.65 or 0.35 * crouch
return x, y, z + z_off
end
Why the offset?
Players have a small “eye height” above their feet. This function gives you the actual world location for collision checks, spawning effects, or distance calculations.
get_table_length
Returns the total number of key-value pairs in a table - works for both array-style and dictionary-style tables (unlike the # operator).
Parameters:
tbl(table) - The table to count.
Returns:
Number of elements.
Code:
local function tableLength(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
Examples:
local t1 = {10, 20, 30, 40} -- tableLength(t1) → 4
local t2 = {name = "Jay", age = 32} -- tableLength(t2) → 2
local t3 = {1, 2, foo = "bar"} -- tableLength(t3) → 3
get_tag_referernce_address
Retrieves the memory address of a tag’s data given its class and name. (Note the typo in the function name - it’s intentional to match the original snippet.)
Parameters:
class(string) - Tag class, e.g.,"weap","vehi".name(string) - Tag name, e.g.,"weapons\\pistol\\pistol".
Returns:
Memory address of the tag data, or nil if not found.
Code:
local function getTag(class, name)
local tag = lookup_tag(class, name)
return tag and read_dword(tag + 0xC) or nil
end
Usage: Use this to dynamically fetch tag data for spawning or modification.
get_weapon_slot
Reads the current weapon slot byte from the dynamic player object.
Parameters:
dyn_player(number) - Dynamic player address.
Returns:
Weapon slot number (byte).
Code:
local function getWeaponSlot(dyn_player)
return read_byte(dyn_player + 0x2F2)
end
Slot values: Usually 0-3 matching the player’s weapon selection.
map_object_scanner
A debugging tool that scans the map’s tag table and prints all weapons, vehicles, and equipment with their metadata.
Code:
local format = string.format
local base_tag_table = 0x40440000
local function getClassName(class)
return class == 0x76656869 and "vehi"
or class == 0x77656170 and "weap"
or class == 1701931376 and "eqip"
end
local function scanMapObjects()
local tag_array = read_dword(base_tag_table)
local tag_count = read_dword(base_tag_table + 0xC)
for i = 0, tag_count - 1 do
local tag = tag_array + 0x20 * i
local class = read_dword(tag)
local class_name = getClassName(class)
if class_name then
local name_ptr = read_dword(tag + 0x10)
local name = (name_ptr ~= 0) and read_string(name_ptr) or "<no-name>"
local meta = read_dword(tag + 0xC)
local tag_data = read_dword(tag + 0x14)
if tag_data ~= 0 then
local b2 = read_byte(tag_data + 0x2)
local b8 = read_byte(tag_data + 0x8)
local d0 = read_dword(tag_data + 0x0)
local d4 = read_dword(tag_data + 0x4)
cprint(format(class_name .. " meta=%u tag_data=0x%X name=%s | b2=%d b8=%d d0=0x%X d4=0x%X",
meta, tag_data, name, b2, b8, d0, d4), 12)
else
cprint(format(class_name .. " meta=%u tag_data=nil name=%s", meta, name), 12)
end
end
end
end
When to use: During map development or when you need to understand what objects a map contains.
parse_args
Splits a string into substrings based on a delimiter - perfect for parsing chat commands or CSV data.
Parameters:
input(string) - The string to split.delimiter(string) - The delimiter character (e.g.," ",",").
Returns:
An array-like table of substrings.
Code:
local function parseArgs(input, delimiter)
local result = {}
for substring in input:gmatch("([^" .. delimiter .. "]+)") do
result[#result + 1] = substring
end
return result
end
Example:
local args = parseArgs("!give weapon sniper", " ")
-- args = {"!give", "weapon", "sniper"}
send_private_or_global_message
A unified function that sends a formatted message either to a specific player or to everyone (without the server prefix).
Parameters:
player_id(number or nil) - Target player index, ornilfor global broadcast....- Formatting arguments forstring.format.
Code:
local format = string.format
local function send(player_id, ...)
if not player_id then
execute_command('msg_prefix ""')
say_all(format(...))
execute_command('msg_prefix "' .. MSG_PREFIX .. '"')
return
end
rprint(player_id, format(...))
end
Usage:
send(nil, "Server restarting in %d seconds!", 30) -- global
send(3, "Hello %s, welcome!", get_var(3, "$name")) -- private
Important: The variable
MSG_PREFIXmust be defined elsewhere in your script. The function temporarily removes it for clean global messages.
set_respawn_time
Overrides a player’s respawn time by writing directly to the player table memory.
Parameters:
playerIndex(number) - Player index (1-16).respawnTime(number, optional) - Seconds until respawn. Defaults to 3.
Code:
local function setRespawnTime(playerIndex, respawnTime)
respawnTime = respawnTime or 3
local static_player = get_player(playerIndex)
if static_player then
write_dword(static_player + 0x2C, respawnTime * 33)
end
end
Note: The value is stored in ticks (1 tick ≈ 1/33 second), so multiply seconds by 33.
shuffle_table_fisher_yates
Randomly shuffles an array-like table in place using the Fisher-Yates algorithm. Every permutation is equally likely.
Parameters:
tbl(table) - The array-style table to shuffle.
Code:
local function shuffleTable(tbl)
for i = #tbl, 2, -1 do
local j = math.random(i)
tbl[i], tbl[j] = tbl[j], tbl[i]
end
end
Requirements:
- Table must have sequential integer keys starting at 1.
- Use
math.randomseed()at script start for true randomness.
Example:
local players = {1, 2, 3, 4, 5}
shuffleTable(players)
-- Now players is in random order
vanish_player
“Vanishes” a player by moving them far off-map. The player sees themselves in a spectator-like state, but others cannot see them.
Parameters:
playerId(number) - Player index.
Code:
local function vanish(playerId)
local static_player = get_player(playerId)
if not static_player then return end
local dyn_player = get_dynamic_player(playerId)
if dyn_player == 0 then return end
local x, y, z = getPlayerPosition(dyn_player)
if not x then return end
local x_off, y_off, z_off = -1000, -1000, -1000
write_float(static_player + 0xF8, x + x_off)
write_float(static_player + 0xFC, y + y_off)
write_float(static_player + 0x100, z + z_off)
end
Important: This must be called every tick (e.g., inside OnTick) to keep the player hidden. The offsets 0xF8, 0xFC, and 0x100 are the player’s world position in the static player table.
Use cases: Invisibility for admins, “ghost” modes, or custom respawn systems.