SCRIPTING.md — Lua gameplay scripts¶
Garry's Mod-style scripting for creators who don't want to compile C++.
Code: kke/ScriptVM.h (the sandboxed Lua state), kke/modules/ScriptModule.h
(scripts in a game; bindings in ScriptModule.cpp and ScriptBindings.cpp);
tests: tests/test_script_vm.cpp. Examples, both in kke_demo:
games/first_lua_game/: your first game in Lua, break-the-targets with a score HUD, a timer and a results screen, in one file. Press T. Its README walks through it.games/showcase/scripts/toys.lua: G builds a crate tower, B throws a ball, N clears.
Using it¶
- Put
*.luafiles in the game'sscripts/folder (or pointKKE_SCRIPTS_DIRat a folder). They load in name order at start. - Hot reload: save a file while the game runs and it reloads within half a second. Its old hooks, timers and spawned bodies are removed first, so nothing doubles up. Deleting a file unloads it.
- Console: the Scripts panel (F1 in kke_demo) lists scripts with
their status, shows
printoutput and errors, and runs one line of Lua (print(camera.position())). - Errors don't stop the game. A broken hook is reported with
file:lineand a traceback and skipped; everything else keeps running. - Each script has its own globals.
score = 0in one file is not seen by another. Share on purpose:shared.best = 42(one table all scripts see), or events withhook.Run("MyEvent", ...). - Realms:
sv_*.luaruns only where the game's physics is the truth (playing offline, or hosting); every other script runs on every player's machine. Hosting, joining or leaving loads/unloadssv_scripts by themselves. (Same prefixes as Garry's Mod;cl_/sh_run everywhere today.)
Multiplayer¶
What an sv_ script spawns while hosting shows up on every player's
machine: physics.box/physics.sphere bodies (moving with the host's,
like the level's crates), breakable.box (breaking into the host's
pieces) and breakable.ball (thrown the same way; players who join
later don't see old throws). Removing one, or reloading the script,
removes it everywhere; what an sv_ script made before you started
hosting goes out when you do. On a client these objects belong to
"(host)" in the Scripts panel and go away when you leave. Their Break
hook fires on clients too (with the client's own id for it).
On a dedicated server (kke_server with the scripts role,
docs/SERVER_HOSTING.md "Scripts") the same sv_ and sh_ scripts run
headless and replicate the same way; there net.role() is "server",
net.send takes a player id to send to one player, and server.* adds
say, kick, score and top plus the PlayerJoin / PlayerLeave
hooks.
Scripts that aren't sv_ run on every machine already, so what they
spawn stays local (each machine makes its own). Other state crosses with
net.send. How it works: docs/NETWORKING.md "Spawned objects" and
"Breakables".
The API¶
Events, like GMod's hook:
hook.Add("Think", "my.id", function(dt) end) -- every frame
hook.Add("Tick", "my.id", function(dt, tick) end) -- fixed 60 Hz
hook.Add("Contact", "my.id", function(c) end) -- two bodies (not the player): c.a, c.b, c.speed, c.materialA, c.materialB, c.pos
hook.Add("Break", "my.id", function(id) end) -- a breakable this script made came apart
hook.Add("NetMessage", "my.id", function(name, data, from) end) -- net.send from another machine
hook.Add("Init", "my.id", function() end) -- once, after all scripts loaded
hook.Add("Shutdown", "my.id", function() end)
hook.Remove("Think", "my.id")
hook.Run("MyEvent", ...) -- your own events between scripts
Timers: timer.Simple(seconds, fn), timer.Create(name, seconds, reps, fn)
(reps 0 = forever), timer.Remove(name), timer.Exists(name).
Vectors: Vec(x, y, z) with + - * /, :length(), :normalized(),
:dot(v), :cross(v). Every binding takes and returns these.
| Table | Functions |
|---|---|
kke |
log(...), time(), dt() (and plain print) |
physics |
box{pos, size, density, material, color, velocity, bounce, friction, static} / sphere{pos, radius, ...} → id; remove(id), position(id), velocity(id), setVelocity(id, v), impulse(id, v [, point]), raycast(from, dir [, maxDist]) → {pos, normal, distance, body, material} or nil, count() |
audio |
impact(pos, material, intensity) (material id or name), materials() → {Stone = 1, Wood = 2, ...} |
input |
define(id, label, defaultKey), pressed(id), held(id), value(id); actions show up in the rebinding screen like any other |
camera |
position(), target(), forward() |
models |
load(name) → model (an asset name from an installed pack, e.g. "SM_Prop_Crate_01", or a path inside the game's folder; nil + reason if missing), spawn(model, {pos, yaw, scale, tint}) → instance, move(inst, pos [, yaw, scale]), remove(inst), tint(inst, Vec), visible(inst, bool), play(inst, clip [, loop, speed]) (clip name or number; nil stops), clips(model) → names, bounds(model) → min, max |
breakable |
FEMFX objects that really break. box{pos, size, material, pattern, cells, chunk, velocity, arm} → id (material glass, stone, wood, ice, iron; pattern shards, voronoi, splinters, radial, solid, default by material), ball{pos, radius, velocity, material} → id (iron by default: a projectile), remove(id), broken(id), pieces(id), count(). The Break hook says when one breaks. |
ui |
RmlUi documents. open(rml) / load("file.rml") (next to the scripts) → doc, text(doc, id, text) (plain text, shown as typed), rml(doc, id, markup), class(doc, id, name, on), property(doc, id, name, value), show(doc, bool), close(doc), onClick(doc, id, fn) |
scene |
list() → names in scenes/, load(name, origin) → scene, missing count (or nil + reason), unload(scene), spawnPoint(scene) → pos, yaw |
net |
role() ("offline", "host", "client"), isServer(), connected(), playerId(), players() → { {id, name}, ... }, send(name, data): from a client to the host, from the host to every client; data is nil, a boolean, number, string or a table of those (up to 1 KB) |
store |
What outlives the session ("Saving" below): save(name, value) → true or false, reason; load(name [, default]); add(name [, n]) → new count; remove(name); keys([prefix]) → names in order |
Everything a script makes (bodies, models, breakables, documents,
scenes, click handlers) belongs to it: reloading, unloading or stopping
the script removes it all. Ids from another script, or made up, are an
error naming the id. Each kind has a per-script budget
(ScriptModule::max*PerScript: 2,000 bodies and models, 64 breakables,
16 documents, 4 scenes).
Play blocks (play)¶
The building blocks of play-to-make (PLAY_TO_MAKE.md),
bound by kke::bindPlayBlocks (kke/PlayScript.h) wherever a game gives
them a world: today the sandbox, where the node graph (Look) runs on them.
Things are numbers (ids), blocks are palette ids ("person", "bat",
"box"), sounds are bonk, wood, stone, metal, glass, rubber,
dirt, plastic.
| Function | Does |
|---|---|
play.spawn(block, pos [, yaw]) → thing |
brings a thing out; it belongs to the script and goes when the script unloads (200 per script) |
play.remove(thing) |
takes it away |
play.ragdoll(thing [, push]) / play.standUp(thing) / play.isDown(thing) |
knocks a person over (push in m/s, at most 20), stands them up, asks |
play.swing(thing) |
swings the bat at it |
play.stagger(thing [, push]) |
shoves a person, who tries to keep their feet (joint motors); a big shove (about 4 m/s and up) still knocks them over and they get up by themselves (PROCEDURAL_ANIMATION.md) |
play.lookAt(thing [, at]) / play.lookAway(thing) |
a person keeps turning their head toward at (a thing, a Vec place, or you when left out), on top of whatever they play; lookAway stops |
play.sound(name [, pos]) |
plays a sound, at a place if given |
play.say(text) |
shows text on screen for a few seconds |
play.addScore(points) → score / play.score() |
points |
play.position(thing) / play.blockOf(thing) / play.blocks() |
where it is, what it is, every block id |
Events (each gets one table):
hook.Add("Hit", "my.id", function(e) end) -- e.target, e.by ("bat"), e.point, e.push, e.block
hook.Add("Clicked", "my.id", function(e) end) -- e.thing, e.point, e.block (tapped with the hand)
hook.Add("Placed", "my.id", function(e) end) -- e.thing, e.point, e.block (put down in the world)
hook.Add("FellOver", "my.id", function(e) end) -- e.thing, e.block
hook.Add("StoodUp", "my.id", function(e) end) -- e.thing, e.block
Bindings registered with a kke::ApiFunction (label, doc, typed
parameters; kke/LuaApi.h) and events described with describeEvent
become node graph blocks automatically.
A table exists only when its module is in the game (no RigidBodyModule,
no physics); store is always there. Script bodies are drawn by ScriptModule as one batched
mesh with shadows.
Saving¶
What should still be there next time (a best score, what a player
unlocked, what someone built) goes in store:
local best = store.load("best", 0) -- 0 the first time (nothing saved yet)
if score > best then store.save("best", score) end
store.save("player.kees", { level = 3, items = { "sword", "map" } })
local kees = store.load("player.kees") -- the same table back
store.add("coins", 5) -- counts up from 0; returns the new count
store.remove("player.kees") -- or store.save("player.kees", nil)
for _, key in ipairs(store.keys("player.")) do print(key) end -- names starting "player.", in order
- A name is 1-256 characters; a value is a number, text, true/false, or a table of those (up to 1 MB saved). Functions can't be saved.
saveandremovereturn true, or false and the reason (a full disk).loadnever breaks a script: when there's nothing (or nothing readable), you get the default.- Where it goes:
save/scripts.dbnext to the game (a SQLite file, docs/STORAGE.md), in the game's own collection, so another game's scripts can't read or change it. Scripts on a host (sv_scripts) save the shared world there; scripts on a player's machine save that player's own things there. - break-the-targets (games/first_lua_game) keeps its best score this way.
Safety and limits¶
- Sandbox: base (without
dofile,loadfile,load,require),string(withoutdump),table,math,utf8,coroutine. Noio,os,debug,package: a script from the marketplace can't read files, run programs or load native code. Only text chunks load (bytecode can be crafted to crash the VM). - Engine tables are read-only for scripts:
physics.box = nilorhook.Add = ...is an error, not a change for everyone. The shared metatables (strings,Vec) are locked too. - Endless loops are stopped after 20 M instructions per call (each
hook, timer, or script load gets its own budget), for good: every call
into Lua runs on its own coroutine, and the budget check yields it away,
straight past any
pcallthe script wrapped around its loop. The script is then unloaded (its hooks, timers and everything it made removed) and marked STOP in the panel until you fix it and save. - CPU time per script (ms per frame) is in the Scripts panel.
- Memory is capped at 64 MB per VM; going over is an ordinary Lua "not enough memory" error.
- Spawn budget: 2,000 bodies per script, 32
Contactevents per frame. - Paths a script names (
models.load,ui.load,scene.load) must be relative and stay inside the game's folders: no absolute paths, no... - Net messages are checked on arrival: a damaged or oversized message is dropped with a warning, never decoded into something half-built.
Why it's built this way¶
- Lua 5.4 (MIT): small, fast, embeddable, and what Roblox (Luau) and Garry's Mod creators already know.
- Compiled as C++, so a Lua error raised inside a C++ binding unwinds
C++ objects properly instead of
longjmp-ing over them. - hook/timer live in Lua (a bootstrap chunk in
ScriptVM.cpp), like GMod's ownhook.lua: easy to read and change. Its privileged helpers (error reporting, budget reset) are locals, out of scripts' reach. - One VM per game, one environment per script (its
_ENV, whose missing names fall through to read-only views of the engine tables). Isolation is what marketplace content needs, and costs a beginner nothing: locals and functions work as before, andsharedis there for data meant to be shared.ScriptVM::Limits::isolateScripts = falsegives GMod's one shared global table back. Each hook/timer remembers its script, which is what makes reload and error attribution work. - Every call on a fresh coroutine (
ScriptVM::resume): the only way to stop a script that catches its own errors. Cheap (a coroutine is a few hundred bytes), and it also gives each call a clean stack.
Next¶
- Replicating a script body moved by
setVelocity/impulseon a client (today the host's copy wins, as with the level's crates), and models (models.spawn) and UI a server script opens. - Character bindings (the player's position, teleport, animation).
breakable.position: FEMFX objects don't expose their centre yet.- Hot-reloading
.rmlfiles a script loaded, like.luafiles.