Skip to content

Latest commit

 

History

History
349 lines (299 loc) · 21.1 KB

File metadata and controls

349 lines (299 loc) · 21.1 KB

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 (on a pad: RB tower, View ball, hold View to clear).

Using it

  • Put *.lua files in the game's scripts/ folder (or point KKE_SCRIPTS_DIR at 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 print output and errors, and runs one line of Lua (print(camera.position())).
  • Errors don't stop the game. A broken hook is reported with file:line and a traceback and skipped; everything else keeps running.
  • Each script has its own globals. score = 0 in one file is not seen by another. Share on purpose: shared.best = 42 (one table all scripts see), or events with hook.Run("MyEvent", ...).
  • Realms: sv_*.lua runs 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/unloads sv_ 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".

Calls

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): ok is 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.call returns 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_server that is everything it sent, spawned, removed, kicked, scored and saved (store.*); in a hosted game, its net.sends and store.* 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.handle replaces it; nil removes it); reloading the script removes its handlers.

Synced tables

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.send carries (a table of plain values, about 400 bytes once encoded). :set replaces the whole row.
  • A filter ({ court = 3 }) keeps the rows whose top-level fields are equal (numbers compare by value: 3 and 3.0 match). 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.table is 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.call undoes 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).

Groups

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 ball
  • net.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.group works 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.

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("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 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, 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).

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.
  • save and remove return true, or false and the reason (a full disk). load never breaks a script: when there's nothing (or nothing readable), you get the default.
  • Where it goes: save/scripts.db next 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 (without dump), table, math, utf8, coroutine. No io, 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 = nil or hook.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 pcall the 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 Contact events 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 own hook.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, and shared is there for data meant to be shared. ScriptVM::Limits::isolateScripts = false gives 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

  1. Replicating a script body moved by setVelocity/impulse on a client (today the host's copy wins, as with the level's crates), and models (models.spawn) and UI a server script opens.
  2. Character bindings (the player's position, teleport, animation).
  3. breakable.position: FEMFX objects don't expose their centre yet.
  4. Hot-reloading .rml files a script loaded, like .lua files.