-
Notifications
You must be signed in to change notification settings - Fork 2
Lua Scripting
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
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.
-
Map-Centric: Each map can have its own Lua script (
baseq2rtxp/maps/scripts/mapname.lua) - Signal I/O Integration: Lua works with the Signal I/O system for entity communication
- State Persistence: Map state variables are saved/loaded with savegames
- No Engine Modification Required: Mappers can create complex interactions without C++ knowledge
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
----------------------------------------------------------------------
-- 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
endThis 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.
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
endPattern: [luaName]_OnSignalIn
The engine automatically calls [luaName]_OnSignalIn when an entity with that luaName receives a signal.
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
endSignals 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-- Print debug message
Core.DPrint("Debug message\n")
-- Get world time
local timeSeconds = Core.GetWorldTime(TimeType.SECONDS)
local timeFrames = Core.GetWorldTime(TimeType.FRAMES)-- 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-- 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"
})-- 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-- 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")-- 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 sourcesThis 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
endMap state is automatically saved/loaded with savegames.
Define in mapStates table:
mapStates = {
customState = {
counter = 0,
playerScores = {},
puzzleSolved = false
}
}These variables persist across:
- Quick saves
- Manual saves
- Level transitions (for same map)
- ✅ Numbers (integers, floats)
- ✅ Booleans
- ✅ Strings
- ✅ Tables (nested structures)
- ❌ Functions
- ❌ Entity references (use targetname strings instead)
Create reusable code in baseq2rtxp/maps/scripts/utilities/:
-- 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 entitieslocal 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
)
endAny entity that needs Lua behavior should have a luaName:
"luaName" "PuzzleDoor"
"targetname" "door_puzzle"
-- GOOD: Persistent state
mapStates.puzzle = {
solved = false,
attempts = 0
}
-- BAD: Local variables (not saved)
local puzzleSolved = false -- Lost on save/load!Always return true from callback functions:
function MyEntity_OnSignalIn(self, signaller, activator, signalName, signalArguments)
-- Handle signal
return true -- Important!
endlocal 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-- GOOD: Use signals for communication
Game.SignalOut(targetEntity, self, activator, "Activate", {})
-- LESS GOOD: Direct state manipulation
targetEntity.state.frame = 5 -- Works but less flexibleIn svg_lua.h:
#define LUA_DEBUG_OUTPUT 1Core.DPrint("Debug: Variable value = " .. tostring(value) .. "\n")Signal not received:
- Check entity has
luaNamekey in map - Verify callback function name:
[luaName]_OnSignalIn - Ensure function returns
true
State not persisting:
- Variables must be in
mapStatestable - Don't use local variables for persistent data
Entity not found:
- Verify
targetnamein map matches Lua code - Check entity exists in map
- Ensure map is compiled correctly
Lua works alongside C++ entity classes:
-
C++ entities send signals (e.g.,
func_doorsends "OnOpen", "OnClose") -
Lua scripts receive signals via
[luaName]_OnSignalIn -
Lua scripts control entities via
Game.UseTarget()andGame.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
- Signal I/O System - Entity communication
- UseTargets System - Entity targeting
- Entity System Overview - Entity architecture
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.