You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is an assessment of, and a concrete proposal for, introducing a scripting layer to the game module (src/game/default) so that mappers and modders can customize entity behavior, physics, ballistics, and combat rules without writing C. It grew out of the discussion in #862, where a relatively small mapping feature (items riding func_trains) raised the broader question: how much map-specific and mod-specific logic should we be able to express in data/script rather than recompiling the game DLL?
TL;DR recommendation: embed PUC-Lua 5.4, sandboxed by environment curation, server-authoritative, exposed through a thin hook API that mirrors the function-pointer surface the game already uses internally. Keep item/weapon definitions data-driven (we already vendor RapidJSON); express behavior in Lua. Explicitly defer scriptable player movement because of client prediction. Ship behind a trust gate with a sandboxed default.
Why the current architecture is a good fit
The game module is already a runtime-loaded plugin (Sys_OpenLibrary("game") → G_LoadGame, src/server/sv_game.c:384-395) that talks to the engine through a clean capability struct (g_import_t / g_export_t, src/game/game.h:193-740). Crucially, the game already dispatches almost all gameplay through per-entity function pointers — which is exactly the shape a scripting layer wants to plug into:
Spawning — the classname → spawn fn table and field parser in G_SpawnEntity() (g_entity.c). A scripted classname (e.g. target_script, or a script key on any entity) is a small, contained extension here.
Combat — G_Damage() (g_combat.c:265) is a single chokepoint for all damage (immunity → multipliers → knockback → armor split → Pain/Die). One pre/post hook here governs every combat rule.
Ballistics — the G_*Projectile() family (g_ballistics.c:241+) plus per-projectile Touch/Think. Custom projectile behavior is just script-supplied callbacks.
Items/weapons — runtime Pickup/Use/Drop/Think function pointers on g_item_t (g_types.h:478-514), assigned per-classname in G_InitItem() (g_item.c).
In other words, we don't need to invent hook points — we need to let scripts supply the callbacks the engine already calls.
The one hard constraint: player movement & prediction
bg_pmove.c is built as libpmove.la and linked into bothgame.la (server) and cgame.la (client) so movement is predicted client-side. Scripting Pm_Move server-only would desync prediction and feel awful (and invite "cheat detected" style corrections). Recommendation: player movement is out of scope for v1. If we ever want it, the same deterministic script would have to run in cgame too (cgame is already a DLL with its own cg_import_t/cg_export_t, src/cgame/cgame.h), which is a separate, much larger project.
Options considered
Option
Sandboxable
Modder-friendly
Perf
Deps/build cost
Notes
PUC-Lua 5.4
✅ via env curation
✅ tiny, ubiquitous
Good
Low (small C lib, autotools-friendly)
Recommended
LuaJIT
⚠️ FFI is an escape hatch
✅
Excellent
Medium; weak/awkward on Apple ARM64
FFI defeats the sandbox
WASM (wasm3 / wasmtime)
✅✅ strongest (memory-isolated, capability-based)
❌ requires a toolchain to compile
Good–Excellent
Higher; ABI/marshalling glue
Best security, worst accessibility for mappers
AngelScript
✅
⚠️ niche, C++-ish
Good
Medium (C++)
Less reach than Lua
QuakeC-style VM
✅
⚠️ archaic
Fine
We'd build/maintain it
Heritage fit, but a maintenance burden
Native module ("don't install if you don't trust")
❌
❌ (still C)
Native
None
Doesn't meet the "no C" goal
Why Lua over WASM despite WASM's superior isolation: the target audience is mappers and lightweight modders, who want to drop a .lua next to a .bsp, not stand up a Rust/C→WASM toolchain. Lua's security gap (it can reach os/io/package/loadfile) is closeable because embedders fully control the global environment handed to a script. We can run untrusted scripts in a curated sandbox with none of those tables present. WASM remains a credible future backend if we ever want to run genuinely hostile code; the hook API below is designed to be VM-agnostic so we aren't married to Lua.
Proposed security model
Security is not "sandbox vs. don't" — it's layered:
Sandboxed by default. Untrusted scripts (anything shipped inside a map/pak that a client downloaded) get a fresh environment with only curated tables: math, string, table, a frozen subset of os (time only), and our quetoo.* API. Noio, os.execute, package, require, loadfile, dofile, load, FFI. This is the standard, well-understood Lua sandboxing approach.
Resource limits. Instruction-count hook (lua_sethook with LUA_MASKCOUNT) to kill runaway scripts; a per-frame time budget; bounded memory via a custom lua_Alloc. A script that blows its budget is disabled with a console warning, not allowed to hang the server.
Server-authoritative. Scripts run only in the game module (server). Clients never execute map scripts; they just receive the resulting entity/network state. This sidesteps a whole class of client-RCE concerns.
Trust gate / capability tiers. A cvar (e.g. g_scripting, default sandboxed; an explicit elevated mode for trusted local mods) gates whether the broader API surface (filesystem reads within the game dir, etc.) is offered. Default install is safe; power users opt in. Down the road this can grow into signed/whitelisted scripts.
This gives us the requested "only turn on sandboxed features" posture as the default, while still allowing the pragmatic "trusted local mod" path — without making "don't install it if you don't trust it" the only answer.
Proposed hook API (VM-agnostic)
Mirror the existing C callbacks. A script registers handlers; the C side invokes them where it currently calls function pointers:
-- maps/torn.lua (loaded for map "torn")localq=require("quetoo")
-- Entity behavior: bind a scripted classname or augment an existing entityq.on_spawn("misc_riding_item", function(ent)
ent.movetype=q.MOVE_TYPE_PUSHent.think=function(self) --[[ follow the train ]]endend)
-- Combat rule hook (pre-damage): scale, cancel, or redirectq.on_damage(function(d) -- d wraps g_damage_tifd.mod==q.MOD_TELEFRAGthend.damage=0endend)
-- Ballistics: custom projectile touchq.on_touch("my_projectile", function(self, other, trace) ...end)
Engine-side, this is additive: a few new entries in g_import_t/g_export_t (script load/eval, hook dispatch), a GAME_API_VERSION bump (currently 29, game.h:27), and shim functions that check "is there a script handler for this entity/event?" before/around the existing C path. No rewrite of game logic — scripts wrap it.
Suggested phasing
Spike (no API commitment): embed Lua, load maps/<name>.lua, expose read-only entity fields + Print. Prove the sandbox and the per-frame cost at tick rate.
Combat/ballistics hooks:on_damage pre/post around G_Damage(); scripted projectile callbacks.
Data-driven defs: move item/weapon tunables to JSON (RapidJSON is already vendored, deps/rapidjson/) so balance mods don't touch code. Keep bg_item_defs/bg_pmove authoritative for anything shared with cgame.
(Maybe, much later) scriptable movement — only with a shared client/server VM and deterministic guarantees.
Open questions
Script discovery & packaging: per-map maps/<name>.lua, a per-mod entry script, or both? How does it ride inside .pk3/pak downloads, and what's the client-download trust story?
State persistence across G_RunFrame and level changes — where do script-owned tables live, and how do they interact with MEM_TAG_GAME_LEVEL lifetimes?
Determinism / demos / replays — do scripted outcomes need to be reproducible for demo playback?
Lua dependency policy — vendor under deps/ (consistent with discord-rpc/minizip/rapidjson) vs. PKG_CHECK_MODULES?
Multiplayer fairness — should map scripts be able to affect combat rules in ranked/stats-reporting matches (PostStats, game.h:652), or only in custom servers?
Interested in thoughts on language choice (Lua vs. WASM-for-isolation) and on the sandbox-by-default + trust-gate model before any implementation work begins.
Assessment produced from a review of src/game/default and the engine↔game boundary; file/line references above are anchors for discussion, not a finished design.
Summary
This is an assessment of, and a concrete proposal for, introducing a scripting layer to the game module (
src/game/default) so that mappers and modders can customize entity behavior, physics, ballistics, and combat rules without writing C. It grew out of the discussion in #862, where a relatively small mapping feature (items ridingfunc_trains) raised the broader question: how much map-specific and mod-specific logic should we be able to express in data/script rather than recompiling the game DLL?TL;DR recommendation: embed PUC-Lua 5.4, sandboxed by environment curation, server-authoritative, exposed through a thin hook API that mirrors the function-pointer surface the game already uses internally. Keep item/weapon definitions data-driven (we already vendor RapidJSON); express behavior in Lua. Explicitly defer scriptable player movement because of client prediction. Ship behind a trust gate with a sandboxed default.
Why the current architecture is a good fit
The game module is already a runtime-loaded plugin (
Sys_OpenLibrary("game")→G_LoadGame,src/server/sv_game.c:384-395) that talks to the engine through a clean capability struct (g_import_t/g_export_t,src/game/game.h:193-740). Crucially, the game already dispatches almost all gameplay through per-entity function pointers — which is exactly the shape a scripting layer wants to plug into:Entity behavior callbacks (
src/game/default/g_types.h):Thinkvoid (*)(g_entity_t *ent)g_types.h:1694Blockedvoid (*)(g_entity_t *ent, g_entity_t *other)g_types.h:1699Touchvoid (*)(g_entity_t *ent, g_entity_t *other, const cm_trace_t *trace)g_types.h:1704Usevoid (*)(g_entity_t *ent, g_entity_t *other, g_entity_t *activator)g_types.h:1709Painvoid (*)(g_entity_t *ent, g_entity_t *other, int16_t damage, int16_t knockback)g_types.h:1714Dievoid (*)(g_entity_t *ent, g_entity_t *attacker, uint32_t mod)g_types.h:1719Other natural funnels:
classname → spawn fntable and field parser inG_SpawnEntity()(g_entity.c). A scripted classname (e.g.target_script, or ascriptkey on any entity) is a small, contained extension here.G_Damage()(g_combat.c:265) is a single chokepoint for all damage (immunity → multipliers → knockback → armor split →Pain/Die). One pre/post hook here governs every combat rule.G_*Projectile()family (g_ballistics.c:241+) plus per-projectileTouch/Think. Custom projectile behavior is just script-supplied callbacks.Pickup/Use/Drop/Thinkfunction pointers ong_item_t(g_types.h:478-514), assigned per-classname inG_InitItem()(g_item.c).In other words, we don't need to invent hook points — we need to let scripts supply the callbacks the engine already calls.
The one hard constraint: player movement & prediction
bg_pmove.cis built aslibpmove.laand linked into bothgame.la(server) andcgame.la(client) so movement is predicted client-side. ScriptingPm_Moveserver-only would desync prediction and feel awful (and invite "cheat detected" style corrections). Recommendation: player movement is out of scope for v1. If we ever want it, the same deterministic script would have to run incgametoo (cgame is already a DLL with its owncg_import_t/cg_export_t,src/cgame/cgame.h), which is a separate, much larger project.Options considered
Why Lua over WASM despite WASM's superior isolation: the target audience is mappers and lightweight modders, who want to drop a
.luanext to a.bsp, not stand up a Rust/C→WASM toolchain. Lua's security gap (it can reachos/io/package/loadfile) is closeable because embedders fully control the global environment handed to a script. We can run untrusted scripts in a curated sandbox with none of those tables present. WASM remains a credible future backend if we ever want to run genuinely hostile code; the hook API below is designed to be VM-agnostic so we aren't married to Lua.Proposed security model
Security is not "sandbox vs. don't" — it's layered:
os(time only), and ourquetoo.*API. Noio,os.execute,package,require,loadfile,dofile,load, FFI. This is the standard, well-understood Lua sandboxing approach.lua_sethookwithLUA_MASKCOUNT) to kill runaway scripts; a per-frame time budget; bounded memory via a customlua_Alloc. A script that blows its budget is disabled with a console warning, not allowed to hang the server.g_scripting, default sandboxed; an explicit elevated mode for trusted local mods) gates whether the broader API surface (filesystem reads within the game dir, etc.) is offered. Default install is safe; power users opt in. Down the road this can grow into signed/whitelisted scripts.This gives us the requested "only turn on sandboxed features" posture as the default, while still allowing the pragmatic "trusted local mod" path — without making "don't install it if you don't trust it" the only answer.
Proposed hook API (VM-agnostic)
Mirror the existing C callbacks. A script registers handlers; the C side invokes them where it currently calls function pointers:
Engine-side, this is additive: a few new entries in
g_import_t/g_export_t(script load/eval, hook dispatch), aGAME_API_VERSIONbump (currently29,game.h:27), and shim functions that check "is there a script handler for this entity/event?" before/around the existing C path. No rewrite of game logic — scripts wrap it.Suggested phasing
maps/<name>.lua, expose read-only entity fields +Print. Prove the sandbox and the per-frame cost at tick rate.Think/Touch/Usefor scripted/augmented entities + the spawn-key path. This alone solves Ability for entities (items) to ride trains and for trains to respawn with riders #862-class problems (custom train riders, map logic).on_damagepre/post aroundG_Damage(); scripted projectile callbacks.deps/rapidjson/) so balance mods don't touch code. Keepbg_item_defs/bg_pmoveauthoritative for anything shared with cgame.Open questions
maps/<name>.lua, a per-mod entry script, or both? How does it ride inside.pk3/pak downloads, and what's the client-download trust story?G_RunFrameand level changes — where do script-owned tables live, and how do they interact withMEM_TAG_GAME_LEVELlifetimes?deps/(consistent with discord-rpc/minizip/rapidjson) vs.PKG_CHECK_MODULES?PostStats,game.h:652), or only in custom servers?Interested in thoughts on language choice (Lua vs. WASM-for-isolation) and on the sandbox-by-default + trust-gate model before any implementation work begins.
Assessment produced from a review of
src/game/defaultand the engine↔game boundary; file/line references above are anchors for discussion, not a finished design.