Skip to content

Scripting layer for the game module: assessment & proposal (Lua, sandboxed) #863

Description

@jdolan

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 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:

Entity behavior callbacks (src/game/default/g_types.h):

Hook Signature Line
Think void (*)(g_entity_t *ent) g_types.h:1694
Blocked void (*)(g_entity_t *ent, g_entity_t *other) g_types.h:1699
Touch void (*)(g_entity_t *ent, g_entity_t *other, const cm_trace_t *trace) g_types.h:1704
Use void (*)(g_entity_t *ent, g_entity_t *other, g_entity_t *activator) g_types.h:1709
Pain void (*)(g_entity_t *ent, g_entity_t *other, int16_t damage, int16_t knockback) g_types.h:1714
Die void (*)(g_entity_t *ent, g_entity_t *attacker, uint32_t mod) g_types.h:1719

Other natural funnels:

  • 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.
  • CombatG_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 both game.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:

  1. 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. No io, os.execute, package, require, loadfile, dofile, load, FFI. This is the standard, well-understood Lua sandboxing approach.
  2. 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.
  3. 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.
  4. 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")
local q = require("quetoo")

-- Entity behavior: bind a scripted classname or augment an existing entity
q.on_spawn("misc_riding_item", function(ent)
  ent.movetype = q.MOVE_TYPE_PUSH
  ent.think = function(self) --[[ follow the train ]] end
end)

-- Combat rule hook (pre-damage): scale, cancel, or redirect
q.on_damage(function(d)         -- d wraps g_damage_t
  if d.mod == q.MOD_TELEFRAG then d.damage = 0 end
end)

-- Ballistics: custom projectile touch
q.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

  1. 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.
  2. Behavior hooks: Think/Touch/Use for 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).
  3. Combat/ballistics hooks: on_damage pre/post around G_Damage(); scripted projectile callbacks.
  4. 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.
  5. (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.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions