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 (on a pad: RB tower, View ball, hold View to clear).
- 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.)
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".
net.send is a message with no answer. When a player's script needs
one ("can I join court 3?"), it calls, and an sv_ script handles:
-- sv_courts.lua: runs where the truth is (the host, a server, or alone)
local players = {}
net.handle("join_court", function(data, from)
local court = players[data.court]
if not court then return nil, "there is no court " .. tostring(data.court) end
if #court >= 4 then return nil, "that court is full" end
court[#court + 1] = from
net.send("court", { court = data.court, players = court }) -- tell everyone
return { seat = #court }
end)
-- courts.lua: runs on every player's machine
net.call("join_court", { court = 3 }, function(ok, answer)
if ok then print("my seat:", answer.seat) else print("no:", answer) end
end)- Every call gets exactly one answer, later (never inside
net.call):okis true with what the handler returned, or false with why not: the handler's own reason (return nil, "why"),"no handler for 'name' on the server","the server's handler for 'name' failed"(a Lua error; the details are in the server's log, not sent to the player),"the server is busy","no answer from the server"(after 10 s) or"the connection to the server was lost". net.callreturns false, and never answers, when it can't be sent (not connected). Without a callback it's sent all the same.- All or nothing: when a handler refuses or fails, what it did is
undone. On
kke_serverthat is everything it sent, spawned, removed, kicked, scored and saved (store.*); in a hosted game, itsnet.sends andstore.*saves (bodies and UI it changed stay). Lua variables are never undone, so check first, then change (as above). - Offline or hosting, the
sv_script is on this machine and answers it on the next frame: the same code works alone and online. - One handler per name (a second
net.handlereplaces it;nilremoves it); reloading the script removes its handlers.
State everyone should see (who is on which court, the score, the queue,
the ranking) goes in a synced table: the sv_ script keeps the rows,
every player's scripts watch them. A watcher gets the rows once, then
only what changed; a player who joins late or reconnects gets the rows
as they are, with no code of your own.
-- sv_courts.lua
local courts = net.table("courts")
courts:set(3, { court = 3, players = { "Kees", "Ann" }, score = "0-0" })
net.handle("point", function(data, from)
local row = courts:get(data.court)
row.score = data.score
courts:set(data.court, row) -- every watcher of court 3 gets an "update"
return true
end)
-- scoreboard.lua: on every machine
local court3 = net.watch("courts", { court = 3 }, function(event, key, row, old)
if event == "insert" or event == "update" then print("court 3:", row.score) end
if event == "delete" then print("court 3 closed") end
-- "ready": the first rows are in; "error": row is nil, the third value says why
end)- Keys are whole numbers or text; a row is what
net.sendcarries (a table of plain values, about 400 bytes once encoded).:setreplaces the whole row. - A filter (
{ court = 3 }) keeps the rows whose top-level fields are equal (numbers compare by value:3and3.0match). Without one, every row. When a row stops matching (the player moves to court 4) its watcher gets a"delete"; the court 4 watcher an"insert". - Changes are sent once a frame (a server tick), one per row however
often it changed; a view's
:rows()and:get(key)read its copy. - Only where the
sv_scripts run can a table change (net.tableis an error on a client); watching works everywhere, alone too. - A table belongs to the script that made it: reloading that script
empties it, and watchers see only what's different after it ran
again. A refused or failed
net.callundoes its table changes too. - Limits: 64 tables, 4,096 rows each, 32 watches per player, 4 filter fields. Every player may watch every table (per-player visibility is planned).
One server can hold many rooms at once, like the courts of a sport center: put each player in a group and they are only sent the players and balls of their own group, plus any groups they choose to watch.
-- sv_courts.lua
net.handle("join_court", function(data, from)
net.setGroup(from, data.court) -- 1 to 10: this court's players and ball
net.solid(from, true)
return true
end)
net.handle("spectate", function(data, from)
net.setGroup(from, 0) -- the lobby
net.showGroups(from, { data.court }) -- and watch this court
net.solid(from, false) -- walk through the ball, never touch it
return true
end)
local ball = physics.sphere { pos = Vec(0, 1, 0), radius = 0.033, group = 3 } -- court 3's ballnet.setGroup(player, group),net.group(player),net.showGroups(player, { group, ... })(up to 64;{}or nil: none),net.solid(player, yes). Groups are 0 to 65535, 0 by default; players and bodies in no group are all in group 0.- Only where the
sv_scripts run (a server, or the host's game); on a client these are an error.net.groupworks everywhere. - A body's group is set when it's made (
physics.box{ ..., group = g },physics.sphere{ ... }). - What groups hide is what is sent: everyone's physics is still one world. Leaving forgets a player's group and makes it solid again.
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("InputStyle", "my.id", function(style) end) -- the player switched devices ("xbox", "keyboard", "touch"...): redo prompts built with input.promptText
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 scriptsTimers: 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, visible, glass, cloudy, group} / sphere{pos, radius, ...} → id (visible = false: collides but isn't drawn, an invisible wall; glass = true: see-through, tinted by color, cloudy 0 clear to 1 milky; the look reaches every player); showHidden([on]) → on: draw the invisible ones faintly, on this machine (building a level; also a checkbox in the F1 Scripts panel); 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 [, padButton]) (pad button names: "a", "b", "x", "y", "lb", "rb", "lt", "rt", "start", "back", "dpad_up"...), pressed(id), held(id), value(id); actions show up in the rebinding screen like any other. Button prompts (INPUT.md "Button prompts"): style([player]) → "keyboard", "xbox", "playstation", "switch", "steamdeck", "steamcontroller" or "touch", the device the player uses now; prompt(action or button [, label, player]) → RML with the button's picture (for ui.rml); promptText("{jump} jump, {sprint} run" [, player]) → RML; glyph(action or button [, player or style]) → the picture's path (for your own <img>), or nil + why. In documents, <prompt action="jump" label="Jump"/> redraws by itself. |
camera |
position(), target(), forward() |
mood |
The sky, sun, fog, colour look and ambience in one go (MOODS.md): set(name) → true, or false and the reason ("clear_day", "golden_hour", "sunset", "night", "misty_morning", ... or a mood file of the game's own in moods/), current() → name, list() → names |
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), profile() → "phone", "desktop" or "console" (the kind of screen, INPUT.md "Phone, PC and console"; every document's body also has the class kke-phone/kke-desktop/kke-console and kke-portrait/kke-landscape for RCSS) |
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 (about 500 bytes encoded, one network event); call(name, data [, function(ok, answer) end]) → sent? and handle(name, function(data, from) end): a question to the host and its answer ("Calls" below); table(name) → a synced table (host/server only: :set(key, row), :get(key), :remove(key), :rows(), :count(), :clear()) and watch(name [, filter] [, function(event, key, row, old) end]) → a view (:rows(), :get(key), :ready(), :count(), :stop()) ("Synced tables" below) |
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).
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.blockBindings 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.
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.
- 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.
- 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.
- 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.