Skip to content

Lua Scripting

WatIsDeze edited this page Dec 9, 2025 · 1 revision

Lua Scripting System

Map-specific scripting system for Q2RTXPerimental that enables dynamic gameplay interactions through Lua scripts.

Location: src/baseq2rtxp/svgame/svg_lua.cpp and svg_lua.h

Overview

Lua in Q2RTXPerimental is explicitly designed for MAP-SPECIFIC scripting. It provides mappers with a powerful way to create dynamic, interactive maps without modifying the game's C++ code. Lua scripts are loaded per-map and can respond to entity signals, control entities, and manage map state.

Key Design Principles

  1. Map-Centric: Each map can have its own Lua script (baseq2rtxp/maps/scripts/mapname.lua)
  2. Signal I/O Integration: Lua works with the Signal I/O system for entity communication
  3. State Persistence: Map state variables are saved/loaded with savegames
  4. No Engine Modification Required: Mappers can create complex interactions without C++ knowledge

Map Script Structure

Location and Naming

Map scripts must follow this naming convention:

baseq2rtxp/maps/scripts/[mapname].lua

For example, map q2rtxp_dev_targetrange.bsp uses script:

baseq2rtxp/maps/scripts/q2rtxp_dev_targetrange.lua

Basic Script Template

----------------------------------------------------------------------
-- Map State Variables - Persisted across save/load
----------------------------------------------------------------------
mapStates = {
    customState = {
        counter = 0,
        isActive = false,
        highScore = 0
    }
}

----------------------------------------------------------------------
-- Map Media - Precached resources
----------------------------------------------------------------------
mapMedia = {
    sound = {}
}

----------------------------------------------------------------------
-- Map Callback Hooks
----------------------------------------------------------------------
function OnPrecacheMedia()
    -- Precache sounds, models, etc.
    mapMedia.sound.custom_sound = Media.PrecacheSound("sounds/custom.wav")
    return true
end

function OnBeginMap()
    -- Called when map starts
    return true
end

function OnExitMap()
    -- Called when map exits
    return true
end

----------------------------------------------------------------------
-- Client Callback Hooks
----------------------------------------------------------------------
function OnClientEnterLevel(clientEntity)
    -- Called when a player connects
    Core.DPrint("Player connected: " .. clientEntity.number .. "\n")
    return true
end

function OnClientExitLevel(clientEntity)
    -- Called when a player disconnects
    return true
end

----------------------------------------------------------------------
-- Frame Callback Hooks
----------------------------------------------------------------------
function OnBeginServerFrame()
    -- Called at start of each server frame
    return true
end

function OnEndServerFrame()
    -- Called at end of each server frame
    return true
end

function OnRunFrame(frameNumber)
    -- Called each frame with frame number
    return true
end

Entity Signal I/O Integration

This is the primary way Lua interacts with entities. Entities with a luaName key in the map editor will have their signals routed to Lua callback functions.

Setting Up Entity-Lua Bindings

In your map editor (e.g., TrenchBroom), add to an entity:

"luaName" "MyButton"

Then in your Lua script, define the callback:

function MyButton_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    if signalName == "OnPressed" then
        Game.Print(PrintLevel.NOTICE, "Button was pressed!\n")
        -- Do something
    end
    return true
end

Signal Callback Naming Convention

Pattern: [luaName]_OnSignalIn

The engine automatically calls [luaName]_OnSignalIn when an entity with that luaName receives a signal.

Example: Button with Lua Response

Map Entity:

{
"classname" "func_button"
"targetname" "my_button"
"luaName" "DoorButton"
"target" "my_door"
}

Lua Script:

function DoorButton_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    if signalName == "OnPressed" then
        -- Get the door entity
        local doorEntity = Game.GetEntityForTargetName("my_door")
        
        -- Check door state
        local doorState = Game.GetPushMoverState(doorEntity)
        
        if doorState == PushMoveState.BOTTOM then
            -- Door is closed, open it
            Game.UseTarget(doorEntity, self, activator, EntityUseTargetType.ON, 1)
            Game.Print(PrintLevel.NOTICE, "Opening door...\n")
        elseif doorState == PushMoveState.TOP then
            -- Door is open, close it
            Game.UseTarget(doorEntity, self, activator, EntityUseTargetType.OFF, 0)
            Game.Print(PrintLevel.NOTICE, "Closing door...\n")
        else
            -- Door is moving
            Game.Print(PrintLevel.NOTICE, "Door is moving!\n")
        end
    end
    return true
end

Signal Arguments

Signals can carry typed data:

function Target_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    if signalName == "OnKilled" then
        -- Access argument by key
        local damage = signalArguments.damage
        local killerName = signalArguments.killer
        
        Game.Print(PrintLevel.NOTICE, 
            "Target killed by " .. killerName .. 
            " with " .. damage .. " damage!\n")
    end
    return true
end

Lua API Reference

Core Library

-- Print debug message
Core.DPrint("Debug message\n")

-- Get world time
local timeSeconds = Core.GetWorldTime(TimeType.SECONDS)
local timeFrames = Core.GetWorldTime(TimeType.FRAMES)

Game Library

Entity Access

-- Get entity by targetname
local entity = Game.GetEntityForTargetName("door_main")

-- Get entity by luaName
local entity = Game.GetEntityForLuaName("MyDoor")

-- Get all entities with targetname (returns array)
local entities = Game.GetEntitiesForTargetName("light_torch")

-- Access entity state
entity.state.frame = 5
entity.state.effects = EntityEffects.ROTATE

Entity Control

-- Use target (activate entity)
Game.UseTarget(entity, sender, activator, EntityUseTargetType.ON, 1)

-- Use target with delay
-- (Uses Game.UseTargetDelay from utilities/entities.lua)

-- Send signal to entity
Game.SignalOut(entity, sender, activator, "CustomSignal", {
    damage = 100,
    type = "explosive"
})

Push Mover States

-- Get state of door/platform/train
local state = Game.GetPushMoverState(entity)

-- States:
-- PushMoveState.BOTTOM       -- At rest position
-- PushMoveState.TOP           -- At extended position
-- PushMoveState.MOVING_UP     -- Currently moving up
-- PushMoveState.MOVING_DOWN   -- Currently moving down

Player Information

-- Get player name
local name = Game.GetClientNameForEntity(playerEntity)

-- Print to chat
Game.Print(PrintLevel.NOTICE, "Message to all players\n")

-- Center print to specific player
Game.CenterPrint(playerEntity, "Message on screen")

Media Library

-- Precache sound (in OnPrecacheMedia)
local soundIndex = Media.PrecacheSound("sounds/custom.wav")

-- Play sound
Media.Sound(
    entity,                      -- Entity to play from
    SoundChannel.VOICE,          -- Channel
    soundIndex,                  -- Sound index
    1.0,                        -- Volume (0.0-1.0)
    SoundAttenuation.NORMAL,    -- Attenuation
    0.0                         -- Time offset
)

-- Sound channels:
-- SoundChannel.AUTO
-- SoundChannel.WEAPON
-- SoundChannel.VOICE
-- SoundChannel.ITEM
-- SoundChannel.BODY

-- Sound attenuation:
-- SoundAttenuation.NONE     -- No falloff
-- SoundAttenuation.NORMAL   -- Normal falloff
-- SoundAttenuation.IDLE     -- Ambient sounds
-- SoundAttenuation.STATIC   -- Static sources

Complete Example: Target Range

This example shows a complete interactive target range with score tracking:

----------------------------------------------------------------------
-- Map State
----------------------------------------------------------------------
mapStates = {
    targetRange = {
        highScore = 0,
        targetsAlive = 0,
        roundActive = false,
        timeStarted = 0
    }
}

mapMedia = {
    sound = {}
}

----------------------------------------------------------------------
-- Target Signal Handler
----------------------------------------------------------------------
function Target_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    if signalName == "OnKilled" then
        -- Decrement targets alive
        mapStates.targetRange.targetsAlive = mapStates.targetRange.targetsAlive - 1
        
        -- Get killer name
        local killerName = Game.GetClientNameForEntity(activator)
        
        -- Check if round complete
        if mapStates.targetRange.targetsAlive <= 0 then
            -- Calculate time
            local timeTaken = Core.GetWorldTime(TimeType.SECONDS) - 
                            mapStates.targetRange.timeStarted
            
            -- Check for high score
            if timeTaken < mapStates.targetRange.highScore or 
               mapStates.targetRange.highScore == 0 then
                mapStates.targetRange.highScore = timeTaken
                Game.Print(PrintLevel.NOTICE, 
                    "New high score by " .. killerName .. ": " .. 
                    timeTaken .. " seconds!\n")
            end
            
            -- Round complete
            mapStates.targetRange.roundActive = false
        else
            -- Notify of kill
            Game.Print(PrintLevel.NOTICE, 
                killerName .. " killed a target! " .. 
                mapStates.targetRange.targetsAlive .. " remaining!\n")
        end
    end
    return true
end

----------------------------------------------------------------------
-- Start Button Signal Handler
----------------------------------------------------------------------
function StartButton_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    if signalName == "OnPressed" then
        if mapStates.targetRange.roundActive then
            Game.Print(PrintLevel.NOTICE, "Round already active!\n")
        else
            -- Start new round
            mapStates.targetRange.targetsAlive = 4
            mapStates.targetRange.roundActive = true
            mapStates.targetRange.timeStarted = Core.GetWorldTime(TimeType.SECONDS)
            
            Game.Print(PrintLevel.NOTICE, "Round started! Kill all targets!\n")
            
            -- Reset all targets
            Game.SignalOut(Game.GetEntityForTargetName("target_1"), 
                         self, activator, "Reset", {})
            Game.SignalOut(Game.GetEntityForTargetName("target_2"), 
                         self, activator, "Reset", {})
            Game.SignalOut(Game.GetEntityForTargetName("target_3"), 
                         self, activator, "Reset", {})
            Game.SignalOut(Game.GetEntityForTargetName("target_4"), 
                         self, activator, "Reset", {})
        end
    end
    return true
end

----------------------------------------------------------------------
-- Map Hooks
----------------------------------------------------------------------
function OnPrecacheMedia()
    mapMedia.sound = {
        target_hit = Media.PrecacheSound("maps/targetrange/hit.wav"),
        round_start = Media.PrecacheSound("maps/targetrange/start.wav")
    }
    return true
end

function OnBeginMap()
    mapStates.targetRange.highScore = 0
    mapStates.targetRange.targetsAlive = 0
    mapStates.targetRange.roundActive = false
    return true
end

Map State Persistence

Map state is automatically saved/loaded with savegames.

State Variables

Define in mapStates table:

mapStates = {
    customState = {
        counter = 0,
        playerScores = {},
        puzzleSolved = false
    }
}

These variables persist across:

  • Quick saves
  • Manual saves
  • Level transitions (for same map)

What Gets Saved

  • ✅ Numbers (integers, floats)
  • ✅ Booleans
  • ✅ Strings
  • ✅ Tables (nested structures)
  • ❌ Functions
  • ❌ Entity references (use targetname strings instead)

Utility Libraries

Create reusable code in baseq2rtxp/maps/scripts/utilities/:

Example: entities.lua

-- utilities/entities.lua
local entities = {}

function entities:UseTargetDelay(entity, sender, activator, useType, useValue, delay)
    -- Create delayed use target
    -- Implementation details...
end

function entities:SignalOutDelay(entity, sender, activator, signalName, args, delay)
    -- Create delayed signal
    -- Implementation details...
end

return entities

Using Utilities

local entities = require("utilities/entities")

function MyButton_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    -- Use utility function
    entities:UseTargetDelay(
        Game.GetEntityForTargetName("door"), 
        self, activator, 
        EntityUseTargetType.ON, 1, 
        2.0  -- 2 second delay
    )
end

Best Practices

1. Use luaName for Interactivity

Any entity that needs Lua behavior should have a luaName:

"luaName" "PuzzleDoor"
"targetname" "door_puzzle"

2. Keep State in mapStates

-- GOOD: Persistent state
mapStates.puzzle = {
    solved = false,
    attempts = 0
}

-- BAD: Local variables (not saved)
local puzzleSolved = false  -- Lost on save/load!

3. Return true from Callbacks

Always return true from callback functions:

function MyEntity_OnSignalIn(self, signaller, activator, signalName, signalArguments)
    -- Handle signal
    return true  -- Important!
end

4. Check Entity Validity

local entity = Game.GetEntityForTargetName("door")
if entity then
    -- Entity exists, use it
    Game.UseTarget(entity, self, activator, EntityUseTargetType.ON, 1)
else
    Core.DPrint("Error: door entity not found!\n")
end

5. Use Signal I/O Over Direct Control

-- GOOD: Use signals for communication
Game.SignalOut(targetEntity, self, activator, "Activate", {})

-- LESS GOOD: Direct state manipulation
targetEntity.state.frame = 5  -- Works but less flexible

Debugging

Enable Debug Output

In svg_lua.h:

#define LUA_DEBUG_OUTPUT 1

Debug Printing

Core.DPrint("Debug: Variable value = " .. tostring(value) .. "\n")

Common Issues

Signal not received:

  • Check entity has luaName key in map
  • Verify callback function name: [luaName]_OnSignalIn
  • Ensure function returns true

State not persisting:

  • Variables must be in mapStates table
  • Don't use local variables for persistent data

Entity not found:

  • Verify targetname in map matches Lua code
  • Check entity exists in map
  • Ensure map is compiled correctly

Integration with C++ Entities

Lua works alongside C++ entity classes:

  1. C++ entities send signals (e.g., func_door sends "OnOpen", "OnClose")
  2. Lua scripts receive signals via [luaName]_OnSignalIn
  3. Lua scripts control entities via Game.UseTarget() and Game.SignalOut()

This separation allows:

  • Complex entity behavior in C++ (doors, monsters, physics)
  • Map-specific logic in Lua (puzzles, scores, events)
  • No engine recompilation for map-specific features

Related Documentation

Summary

Lua scripting in Q2RTXPerimental:

  • Purpose: Map-specific interactive gameplay
  • Integration: Works through Signal I/O system
  • Scope: Per-map scripts with persistent state
  • API: Access entities, send signals, play media
  • Workflow: Map entities → Lua callbacks → Entity control

This system enables rich, interactive maps without modifying the game engine.

Clone this wiki locally