Useful Lua Tips & Tricks for Game Scripting

Lua SAPP Game Development Tips & Tricks

This post is a practical guide for game developers and Halo server scripters using Lua. It covers useful SAPP-specific methods, pure Lua functions, and tips for working with map votes, player inventory, custom spawn systems, and more. Whether you're maintaining a server or creating custom game modes, these examples will help streamline your scripting workflow.

SAPP-Specific Methods

Map Vote / Map Cycle Systems

Do not use mapvote_add in init.txt; add entries directly to mapvotes.txt.

Format: map:variant:name:min:max

Property Description
mapName of the map
variantName of custom game mode. Use quotes for spaces, e.g., "My Custom Mode".
nameUser-defined message shown next to map vote option.
minMinimum players
maxMaximum players

Add max_idle 1 to init.txt to avoid 60-second hang on server boot.

map map_name mode
-- Example: map ratrace MyCustomCTF

SAPP Built-in rand() Function

local t = {'a', 'b', 'c'}
local i = rand(1, #t + 1)
print(t[i])
-- Ensures i ranges from 1 to #t

write_vector3d() for Vehicles

  • Add small Z velocity (e.g., -0.025)
  • Unset no-collision & ignore-physics bits
write_bit(object + 0x10, 0, 0)
write_bit(object + 0x10, 5, 0)

spawn_object() with Custom Rotation

Rotation values must be in radians.

Custom Spawn Systems

local r = pR      -- rotation in radians
local z_off = 0.3 -- prevent falling through the map
local x, y, z = px, py, pz + z_off
write_vector3d(dyn + 0x5C, x, y, z)
write_vector3d(dyn + 0x74, math.cos(r), math.sin(r), 0)

Assigning More Than 2 Weapons

Delay assignment by ≥250ms to prevent extra weapons from dropping. See example script.

$pn Server Variable

-- On player leave, prints total players minus 1
function OnLeave()
    local n = tonumber(get_var(0, "$pn")) - 1
    print('Total Players: ' .. n)
end

Get Player Inventory

-- Returns table of weapons with ammo, clip, and stats
-- Usage: local inv = get_inventory(dyn_player)
local function get_inventory(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),
                age = read_float(object + 0x240),
                frags = read_byte(dyn_player + 0x31E),
                plasmas = read_byte(dyn_player + 0x31F)
            }
        end
    end
    return inventory
end

Retrieve Weapon Slot

-- Returns currently selected weapon slot
-- Usage: local slot = get_weapon_slot(dyn_player)
local function get_weapon_slot(dyn_player)
    return read_byte(dyn_player + 0x2F2)
end

Get Tag Reference Address

-- Usage: local tag_addr = get_tag('weap', 'sniper')
local function get_tag(class, name)
    local tag = lookup_tag(class, name)
    return tag and read_dword(tag + 0xC) or nil
end

Get Current Weapon Tag Identifier

-- Usage: local weapon_id = get_current_weapon(dyn)
local function get_current_weapon(dyn)
    local weapon_id = read_dword(dyn + 0x118)
    local weapon_obj = get_object_memory(weapon_id)
    if weapon_obj == nil or weapon_obj == 0 then return nil end
    return read_dword(weapon_obj)
end

Check if Player is in Vehicle

-- Returns true if player is inside a vehicle
-- Usage: if is_in_vehicle(dyn) then ...
local function is_in_vehicle(dyn)
    return read_dword(dyn + 0x11C) ~= 0xFFFFFFFF
end

Clear Player's RCON Console

-- Usage: clear_rcon_console(1)
local function clear_rcon_console(player_index)
    for _ = 1, 25 do rprint(player_index, " ") end
end

Broadcast Message Excluding One Player

-- Usage: send_message_exclude("Hello!", 2)
local function send_message_exclude(message, exclude_player)
    for i = 1, 16 do
        if player_present(i) and i ~= exclude_player then
            say(i, message)
        end
    end
end

Check Player Invisibility State

-- Returns true if player is invisible
-- Usage: if is_player_invisible(1) then ...
local function is_player_invisible(player_index)
    local dyn = get_dynamic_player(player_index)
    return dyn ~= 0 and read_float(dyn + 0x37C) == 1
end

Get Player World Coordinates

-- Usage: local x, y, z = get_player_position(dyn_player)
local function get_player_position(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_offset = (crouch == 0) and 0.65 or 0.35 * crouch
    return x, y, z + z_offset
end

Get Objective (oddball or flag)

-- Usage: if has_objective(dyn_player, "oddball") then ...
local function has_objective(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

Get Flag Object Meta & Tag Name

--[[
    get_flag_data() → flag_meta_id, flag_tag_name

    Retrieves the meta ID and tag name of the flag (objective) in the map.

    Returns:
        flag_meta_id   (number)  → Memory reference ID of the flag tag.
        flag_tag_name  (string)  → Name of the flag tag.

    Notes:
        - Iterates through all tags in the base tag table.
        - Checks for weapon class ("weap") with the specific bit set for objectives.
        - Only returns the first tag where the objective type byte equals 0 (flag).
        - Returns nil, nil if no valid flag is found.

    Example Usage:
        local meta_id, tag_name = get_flag_data()
        if meta_id then
            print("Flag Meta ID:", meta_id)
            print("Flag Name:", tag_name)
        else
            print("No flag found in this map.")
        end
]]

local flag_meta_id, flag_tag_name
local function get_flag_data()
    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

Check if Player is In Range

-- Usage: if in_range(x1, y1, z1, x2, y2, z2, 5) then ...
local function in_range(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
end

Send Global/Private Message

-- Usage: send(nil, "Hello world") or send(1, "Hi Player 1")
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

Pure Lua Functions

String Split

-- Usage: local parts = string_split("a,b,c", ",")
local function string_split(input, delimiter)
    local result = {}
    for substring in input:gmatch("([^" .. delimiter .. "]+)") do
        result[#result + 1] = substring
    end
    return result
end

Table Length (Key Count)

-- Usage: local count = table_length({a=1, b=2})
local function table_length(tbl)
    local count = 0
    for _ in pairs(tbl) do count = count + 1 end
    return count
end

Shuffle Table Elements (Fisher-Yates)

-- Usage: shuffle_table(my_table)
local function shuffle_table(tbl)
    for i = #tbl, 2, -1 do
        local j = math.random(i)
        tbl[i], tbl[j] = tbl[j], tbl[i]
    end
end

Deep Copy Table

-- Usage: local copy = deep_copy(orig_table)
local function deep_copy(orig)
    if type(orig) ~= "table" then return orig end
    local copy = {}
    for k, v in pairs(orig) do
        copy[deep_copy(k)] = deep_copy(v)
    end
    return setmetatable(copy, deep_copy(getmetatable(orig)))
end

Non-blocking Timer Using Coroutines

-- Usage:
-- co = timer(2, function() print("2 seconds passed") end)
-- coroutine.resume(co) repeatedly until completion
local function timer(delay, func)
    local co = coroutine.create(function()
        local start = os.clock()
        while os.clock() - start < delay do coroutine.yield() end
        func()
    end)
    return co
end

Chalwk

Independent Game Developer & Open-Source Enthusiast

Creating innovative gaming experiences from New Zealand. Passionate about Lua scripting and game server development.