Everything you need to build mods for SPASTAZ: game modes, entities with physics, weapons, effects, menus, whole new ways to play like the Sandbox, scripts that ride inside a map, and how to debug and test all of it while the game is running.
What you can build
A mod is a folder in src/mods/ with a mod.cfg file in it. The game loads every enabled mod at startup, sends it events every frame, and reloads it the moment you save a file. Mods are written in Lua, in OrphanASM (the engine's own assembly language), or both.
Game modes
Rules, teams, kits, rounds, objectives, bots, a HUD. They're played online like the built-in modes, and joining players get the mod from the server (chapter 6).
Things in the world
Entities with a look, real physics and Lua hooks; weapons with their own code; constraints, ropes and motors; particles, lights, explosions; 3D text and screens (chapters 8–10).
Everything around it
Windows and menus, the camera for films, the medical simulation and drugs, doors, power and weather, scripts that come inside a map, and hooks that can stop or change what the game does (chapter 7).
Mods are sandboxed. They can't touch files outside their own folder, run programs, or freeze the game: every call has a time budget and every Lua mod has a memory cap. A broken mod shows an error on the MODS screen instead of taking the game down.
These mods ship in src/mods/. Reading them next to this guide is the fastest way to learn:
| Mod | Kind | Shows you |
|---|---|---|
hello_lua | Lua | Console commands, events, a task, saving stats between sessions |
asm_tutorial | ASM | A guided tour: loops, recursion, strings, floats, macros, drawing |
sandbox | Lua mode | The big one: a physics gun, a tool gun with 22 tools, a spawn menu, undo, a duplicator. Entities, weapons, constraints, UI and realms all at once (chapter 16). |
director | Lua | F7 opens a film director: recorded views, dolly shots from keyframes, orbit and follow cameras, a letterbox (chapter 15) |
gang_wars | Lua mode | Two gangs with their own colours and names; SWAT pull up 1:30 in, for both of them |
hostage_rescue | Lua mode | Civilians, an objective line, and markers that only one side sees |
last_stand | Lua mode | One side against waves of bots, with supplies dropped on the ground between waves |
zombies | Lua mode | Waves of the dead: walkers, runners, crawlers, leapers and brutes, with bots driven from Lua |
surf | Lua mode | Source-style surfing: movement settings, checkpoints, a timer and the server's best runs |
The test mods in tests/luatest/ are small and exercise one part of the API each, so they're good reading too. The full list of every function, generated from the game's own docs, is docs/MOD_API.md in the source, and the same text is in Orphan Editor's F1 panel.
Setup and tools
Build the game and the editor
From the project folder, run build.bat, or use Geany's Build menu, which calls the same script.
| Command | Builds |
|---|---|
build.bat all | The engine library lib/liborphan.a, then bin/spastaz.exe and bin/orphan_editor.exe |
build.bat game | Engine library, then the game |
build.bat editor | Engine library, then Orphan Editor |
build.bat engine | Just lib/liborphan.a |
build.bat run-game / run-editor | Starts one of them from src/ |
Both programs run with src/ as their working folder, so every path in this guide, like mods/hello_lua/main.lua, is relative to src/.
The three places you'll work
- Orphan Editor (
bin/orphan_editor.exe): a code editor made for mods, with autocomplete and docs for the whole API (pressF1), live error checking, a preview window that runs your mod, a debugger, and a live link to the running game. - The MODS screen: main menu → MODS. Switch mods on and off, see errors, change mod settings, play maps.
- The dev console: press
`in the game. Every mod command lives here (cheat sheet).
You can use any text editor instead of Orphan Editor. Hot reload works either way: save the file and the game reloads the mod within a second.
Editor shortcuts worth knowing
| Key | Does |
|---|---|
F5 | Run the current file in the preview with the debugger (or continue when paused) |
Ctrl+F5 | Run without the debugger |
F9 | Toggle a breakpoint (or click left of the line numbers) |
F10 / F11 / Shift+F11 | Step over / into / out |
F1 | API reference panel |
Ctrl+Space | Autocomplete (it also pops up while you type) |
Ctrl+P / Ctrl+Shift+P | Open a file by name / run any editor command |
Ctrl+Shift+G | Launch the game (the editor attaches to it on its own) |
Your first mod
This walks through a small Lua mod, from an empty folder to a feature with a setting, in about ten minutes.
- Create it.In Orphan Editor pick Mods → New Lua Mod and call it
first_mod. You can also typemod_new first_modin the game console, or click NEW MOD on the MODS screen. You getmods/first_mod/mod.cfgand a workingmain.lua. - Say hello.Replace
main.luawith:
Pressorphan.on("load", function() orphan.ok("first_mod is alive") end) orphan.command("greet", "Say hi", function(name) orphan.log("hi " .. (name or "operator")) end)Ctrl+Shift+Gto start the game, open the console with`and typegreet Sam. - Draw something.Add a draw handler. Save, and it appears in the running game without a restart:
orphan.on("draw", function() local w = orphan.draw.width() orphan.draw.rect(w - 230, 60, 220, 40, { 0, 0, 0, 160 }) orphan.draw.text("FIRST MOD", w - 220, 70, 20, "lime") end) - Keep state across reloads.Plain locals reset every time you save. Anything you put in
orphan.persistsurvives:local clicks = orphan.persist.clicks or 0 orphan.on("update", function(dt) if orphan.input.pressed("F10") then clicks = clicks + 1 orphan.persist.clicks = clicks end end) - Add a setting.
orphan.newcvarmakes a saved setting that appears on your mod's page in the MODS screen. A boolean default gives a switch, a min and max give a slider:
Uselocal show = orphan.newcvar("first_show", true, "Show the panel") local size = orphan.newcvar("first_size", 20, "Text size", 10, 40) orphan.on("cvar", function(name, value) if name == "first_show" then show = value end if name == "first_size" then size = value end end)showandsizein your draw handler, then flip the switch on the MODS screen. - Debug it.With the editor attached (the status bar says SPASTAZ ATTACHED), click left of the line number on a line inside your update handler. The next time that line runs, the game pauses your mod there and the editor shows its variables. Press
F10to step,F5to continue. - Fill in the manifest.Open
mod.cfgand give your mod a real name, version and description. That's what players see on the MODS screen (chapter 4).
The manifest: mod.cfg
name = Hover Pads
version = 1.0
author = You
description = Pads that float things above them, for the Sandbox
entry = main.lua # main.oasm for OrphanASM
enabled = 1 # load at startup
priority = 10 # lower loads first (after requirements)
api_version = 1 # the mod API this was written for
requires = sandbox >= 1.0
network = required # servers running it need joining players to have it
private = sv_, server/ # files a server keeps to itself (chapter 14)
| Key | Default | Meaning |
|---|---|---|
name | folder name | Display name on the MODS screen |
version | (none) | Dotted numbers, like 1.2 or 1.10.3. Other mods can require a minimum version. |
author, description | (none) | Shown on the MODS screen |
entry | main.lua | The file that runs. .oasm, .asm or .s means OrphanASM. none: no code (files other mods use). |
enabled | 0 | The MODS screen and mod_enable/mod_disable rewrite this line for you |
priority | 0 | Lower numbers load and get events first |
api_version | assumed 1 | See below |
requires | (none) | Mods that must be running first, see below |
network | (none) | Whether a server running it needs players to have it: required, mode, or left out. See below. |
private | (none) | File name starts or folders a server never sends to players: server-only code (chapter 14) |
Network: playing online
network says whether a server running your mod needs the players joining it to have it too:
required: always, while it's running on the server. Use it for anything that changes the game: rules, entities, weapons.mode: only while one of its game modes is the one being played.- left out: it's only this player's business (HUDs, tools).
A mod that makes the game mode being played is always required. Players who haven't got it can download it from the server when they join (chapter 21).
Requirements
requires is a comma-separated list of mod folder names. Each can carry a version check with >=, >, =, < or <=. Versions compare as numbers per part, so 1.10 is newer than 1.9.
- If a requirement is missing, disabled, broken or too old, your mod doesn't load, and the MODS screen says exactly which one and why.
- Enabling a mod (the MODS switch or
mod_enable) also enables everything it requires, all the way down. - Disabling a mod stops the mods that need it. When it comes back, they start again on their own.
- A cycle (
arequiresb,brequiresa) is reported as an error on both.
Load order
Mods load requirements first, then by priority, then by folder name. The same order decides who gets events first, and whose answer wins when several mods answer the same veto or question. mod_order in the console prints it.
API versions
The engine's mod API version is 1. Put api_version = 1 in every mod.
| Your mod says | What happens |
|---|---|
| nothing | Loads, with a warning to add api_version |
| lower than the engine | Loads, with a warning to check what changed (chapter 24) |
| the same | Loads normally |
| higher than the engine | Refuses to load: the player needs a newer SPASTAZ |
Hot reload
The game checks each loaded mod's folder about once a second (turn it off with mod_hotreload 0). Any changed file reloads the mod: its unload handlers run, textures and sounds are freed, and the whole mod starts fresh. *.save files don't count, so a mod saving its own data doesn't reload itself. With the editor attached, a save reloads the mod straight away.
Lua reference
Every Lua mod runs in its own Lua 5.4 state. You get the base, string, table, math, utf8 and coroutine libraries plus a safe part of os (os.time, os.clock, os.date). There's no io, debug or package, load only takes text, and print goes to the console. Everything else is under the global orphan table.
Events
Register handlers with orphan.on(event, fn). You can have several handlers per event. orphan.on returns the function so you can remove it later with orphan.off(event, fn).
| Event | Arguments | When |
|---|---|---|
load | (none) | After your main file ran |
unload | (none) | Before the mod unloads or hot reloads |
update | dt | Every frame |
draw | (none) | Every frame, after the game drew. The only place 2D drawing works. |
draw3d | (none) | During the 3D scene pass (in the game, not the menu) |
key | name | A key was pressed while the console is closed ("F10", "A"...) |
cvar | name, value | One of your settings changed |
round_start | n | A round of a match started (chapter 6) |
round_end | winner, why | A round ended: the winning team (-1 a draw) and the reason shown |
death | victim, killer | Someone in the match died (actor ids; killer -1 when nobody did it) |
spawn | id, team | Someone (re)spawned in the match |
| anything else | whatever was sent | A custom event from orphan.emit (see below) |
The game sends many more: wounds, broken bones, explosions, doors, entities, and the vetoes that can stop a shot or a respawn. Chapter 7 lists them all.
Core functions
| Function | Does |
|---|---|
orphan.log(...), ok, warn, error | Print to the console in white, green, orange or red. error doesn't stop your script. |
orphan.exec(command) | Run a console command, e.g. orphan.exec("screenshot") |
orphan.command(name, help, fn) | Add a console command. Arguments arrive as strings. Names can't clash with engine commands. |
orphan.cvar(name) | Read any console variable (number, string or boolean, nil if missing) |
orphan.setcvar(name, value) | Set a console variable. Cheat cvars need sv_cheats 1. |
orphan.newcvar(name, default, help[, min, max]) | Make a saved setting (chapter 19) |
orphan.emit(event, ...) | Send an event to every loaded mod, Lua and ASM. Arguments: numbers, strings, booleans. |
orphan.time(), dt(), frame(), fps() | Clock, frame length, frame counter, frames per second |
orphan.screen() | Width and height of the screen (or the editor preview) |
orphan.cheats(), developer() | Whether sv_cheats / developer mode are on |
orphan.inspect(value[, depth]) | A readable dump of any value, tables included |
orphan.clamp(v, lo, hi), lerp(a, b, t), color(r, g, b[, a]) | Small helpers |
orphan.mod, dir, version, engine, gamename | Your folder name and path, the engine version, "Orphan Engine", "SPASTAZ". (orphan.game is now the match API, chapter 6.) |
Tasks and waiting
Every handler runs as a coroutine, so you can wait without freezing the game. orphan.wait pauses just your code; the game keeps running.
orphan.on("round_start", function()
orphan.wait(2) -- 2 seconds later, same handler
orphan.game.banner("GOOD LUCK")
end)
local id = orphan.every(5, function() -- every 5 s; return false to stop
orphan.log("still here")
end)
orphan.after(30, function() orphan.cancel(id) end)
| Function | Does |
|---|---|
orphan.task(fn[, delay]) | Run fn as a background task. Returns an id. |
orphan.wait(seconds) | Pause the current handler or task |
orphan.after(seconds, fn) | Call once, later |
orphan.every(seconds, fn) | Call repeatedly until fn returns false |
orphan.cancel(id) | Stop a task |
Drawing
Drawing functions only work inside a draw handler. Coordinates are screen pixels (in the editor preview, a 640×360 canvas).
| Function | Draws |
|---|---|
orphan.draw.rect(x, y, w, h[, color]) | Filled rectangle |
orphan.draw.rectLines(x, y, w, h[, color[, thickness]]) | Rectangle outline |
orphan.draw.line(x1, y1, x2, y2[, color[, thickness]]) | Line |
orphan.draw.circle(x, y, r[, color]), circleLines | Filled / outlined circle |
orphan.draw.triangle(x1, y1, x2, y2, x3, y3[, color]) | Filled triangle, any winding |
orphan.draw.text(text, x, y[, size[, color]]) | Text in the default font (size 20) |
orphan.draw.textWidth(text[, size]) | Width in pixels, for centring |
orphan.draw.width(), height() | Drawing area size |
orphan.draw.image(tex, x, y[, w, h[, tint]]) | A texture, optionally stretched |
orphan.draw.imageEx(tex, cx, cy[, scale[, rotation[, tint]]]) | A texture centred on a point, scaled and rotated (degrees) |
orphan.draw.sprite(tex, sx, sy, sw, sh, x, y[, w, h[, rotation[, tint]]]) | Part of a texture: sprite sheets. Negative sw/sh flips it. |
Colours can be a name ("red", "lime", "white", "orange", "gray"...), a hex string ("#ff8800" or "#ff880080"), a number 0xRRGGBBAA, or a table { r, g, b, a } with values 0–255.
Textures, sounds and music
Load files from your own mod folder. Loading the same path twice returns the same id, and everything is freed when your mod unloads.
local badge = orphan.gfx.load("assets/badge.png") -- id, width, height
local ping, err = orphan.sound.load("assets/ping.wav")
if not ping then orphan.warn(err) end
orphan.on("round_start", function() orphan.sound.play(ping, 0.6) end)
orphan.on("draw", function()
orphan.draw.imageEx(badge, 60, 60, 1.0, orphan.time() * 45)
end)
| Function | Does |
|---|---|
orphan.gfx.load(path) | Load png, jpg, bmp, tga, gif or qoi. Returns id, w, h, or nil, error. |
orphan.gfx.size(id), unload(id), count() | Size, free early, how many are loaded |
orphan.sound.load(path) | Load wav, ogg, mp3, flac or qoa. Returns an id, or nil, error. |
orphan.sound.play(id[, volume[, pitch[, pan]]]) | Volume 0–1, pitch 1 is normal, pan 0 left to 1 right |
orphan.sound.stop(id), playing(id), unload(id) | Control a sound |
orphan.music.play(path[, volume[, loop]]) | Stream a music file. One track per mod, loops by default. |
orphan.music.stop(), volume(v), playing() | Control the music |
Input
Input functions return false while the console is open, so your mod never reacts to typing.
| Function | Returns |
|---|---|
orphan.input.down(key), pressed(key), released(key) | Key state. Names like "A", "SPACE", "LEFT", "F5", "ENTER". The console's keylist shows them all. |
orphan.input.mouse() | x, y |
orphan.input.mouseDown(button), mousePressed(button) | "left", "right" or "middle" |
orphan.input.wheel() | Wheel movement this frame |
Files and saved data
All paths are inside your mod folder. .., drive letters and absolute paths are refused.
| Function | Does |
|---|---|
orphan.save(name, table) / orphan.load(name) | Keep a table between game sessions, in <mod>/<name>.save |
orphan.persist | A table that survives hot reloads (but not a restart) |
orphan.serialize(v) / deserialize(text) | Tables to text and back. No code runs when reading. |
orphan.fs.read(path), write(path, text), append | Plain file access |
orphan.fs.exists, list([folder]), remove, mkdir | Folder helpers |
require("module") | Load module.lua from your mod folder (dots become folders) |
orphan.include("module") | require that knows about realms: cl_ files are skipped on a dedicated server, private sv_ files where they weren't sent |
Custom events between mods
orphan.emit is how mods talk to each other. A mod that requires another can listen for its events:
-- in weather_mod
orphan.emit("weather", "rain", 0.8)
-- in any other mod (Lua)
orphan.on("weather", function(kind, strength) ... end)
; in an OrphanASM mod: a label named on_weather gets the numbers in r1../f0..
orphan.ask(event, ...) is the same with an answer: every mod's handlers run in load order until one returns something, and that's what ask returns (nil when nobody answered). The game asks this way for its vetoes.
Calling OrphanASM from Lua
local prog = orphan.asm.load("fast.oasm")
local r0, f0 = prog:call("sum_to", 1000) -- ints go to r1.., floats to f0..
A program also has :reg(i[, v]), :freg(i[, v]), :peek(addr[, "i32"|"u8"|"f32"]), :poke(addr, v[, type]), :string(addr), :label(name), :status() and :reset().
Memory
orphan.mem.stats() returns the process's workingSet, peak, private, cpu, threads, and lua (bytes used by your mod). orphan.mem.lua() returns just the last one.
Game modes and the match: orphan.game
orphan.game is everything a mod can see and do in a match, and how a mod adds whole game modes. A mode you register shows up in PLAY > GAME MODE and in mp_gamemode <id> next to the built-in ones, and it plays online: players who join without your mod are offered it.
Who decides. Your mod runs on every machine in the match, but only the machine running the match (offline, or the host) decides things. The reading calls work anywhere. The doing calls only work on the host: anywhere else they do nothing and return false. orphan.game.host() tells you which you are.
Reading the match
| Function | Returns |
|---|---|
host() | true when this machine runs the match |
me() | Your actor id (-1 when you're not in it) |
state() | "warmup", "planning", "freeze", "live", "post" or "over" |
time(), roundtime() | World seconds; seconds left in the round |
score(team), modeid() | Rounds that side has won; the id of the mode being played |
players([team]) | The ids of everyone in the match, or on one side |
actor(id) | A table describing them (below), or nil |
alive(team) | How many on that side are still in the round |
spawns(class) | { {x, y, z, yaw}, ... }: the map's markers of that class, e.g. "spawn_swat", or your own (see Maps for your mode) |
weapons(), items() | The names give and item take |
actor(id) returns { id, name, team, alive, bot, health, x, y, z, yaw, weapon, kills, deaths, arrested, hostage, extracted, surrendered, down }.
Teams: 0 and 1 are the two sides, 2 is civilians, 3 is the police (a third side, against both, that a mode can bring in). Weapons are named the way people say them: "glock17", "m9", "m1911", "ak47", "m4a1", "mp5", "m870", "frag", "flashbang". Items include "fentanyl", "bandage" and "tourniquet".
Changing the match (host only)
| Group | Function | Does |
|---|---|---|
| Players | strip(id[, keepKnife]) | Takes away guns, grenades and armour |
armor(id, kind) | "swat", "light", "vest" or "none" | |
give(id, weapon[, mags]) | A gun (loaded) with spare magazines, or a grenade | |
item(id, item[, n]) | Puts items in their inventory | |
teleport(id, x, y, z[, yaw]), respawn(id), kill(id), setteam(id, team) | Moves, brings back, kills or switches someone | |
| Bots | addbot(team, name) | A bot on that side, spawned now. Returns its id. Team 3 makes the police. |
kick(id) | Takes a bot out of the match | |
| The round | banner(title[, sub]) | The big line across everyone's screen |
notice(text[, id]) | A line on one player's HUD (everyone's without an id) | |
chat(text), sound(name[, x, y, z]) | A chat line; a game sound, at a spot or everywhere | |
endround(team, why) | Ends a live round now (team -1: a draw) | |
exec(command) | Runs a console command on the host (not for downloaded mods) | |
| The world | drop(what, x, y, z[, n]) | Puts a gun (loaded), a grenade, a knife or an item on the ground |
State everyone sees
What the host sets here goes out to every player with the game's snapshots, so a hud hook on any machine can read it. All of it is cleared when a match starts.
| Function | Slots | Does |
|---|---|---|
set(slot, number) / get(slot) | 1-16 | Shared numbers: scores, timers, a wave counter |
text(slot, line) / text(slot) | 1-4 | Shared lines of text |
objective(line) | - | Drawn under the round timer for everyone ("" clears it) |
marker(slot, x, y, z, label[, team[, color]]) / marker(slot) | 1-8 | A diamond with its label and distance on the HUD of everyone on team (left out or -1: everyone). The colour is "#rrggbb" or {r, g, b}. marker(slot) removes it. |
screen(x, y, z) | - | Where a world point is on the screen (nil behind the camera), for drawing in a hud hook |
Bodies, movement and looks (host only)
These change one person or bot. Movement is predicted on the player's own machine, so it feels instant online.
| Function | Does |
|---|---|
health(id[, hp]), heal(id) | Reads or sets hit points (with the organism on: the blood left, of 100). heal patches them up completely. |
god(id[, on]), damagescale(id, k) | Nothing harms their body (hits still land, so your mode can keep its own score), or everything hurts them k times as much |
hurt(id, amount[, {by=, kind=, zone=, weapon=}]) | Your own attack: kind is "blow", "cut" or "bite"; zone is "head", "torso", "arms" or "legs" |
knockdown(id[, s]), stun(id[, s]), freeze(id[, on]) | Knocked flat, stunned, or unable to move |
movement(id[, {...}]) | How they move. speed, jump, gravity, friction and accel are multipliers (jump=0: no jumping). airaccel and aircap give Source-style air control for surfing and bunny hops, autohop jumps again on landing, maxspeed caps speed, animated moves them by code instead of the physical body, and tumble=false means falls never hurt them or knock them down. With no table it returns the current values. |
gait(id[, "biped"|"crawl"|"quadruped"]) | On two feet, dragging along the floor, or on all fours (low, fast, can leap) |
velocity(id[, x, y, z]), push(id, x, y, z), leap(id, forward[, up]) | Reads or sets their velocity, adds to it, or launches them the way they face |
outfit(id, {skin=, shirt=, sleeves=, pants=, shoes=, gloves=, hat=, mask=, shortSleeves=, vest=, helmet=}) | Their clothes and skin. Colours are {r, g, b} or "#rrggbb", and anything you leave out stays the game's own. outfit(id, nil) gives them their own look back. |
tag(id, text[, color]), setname(id, name) | A line over their head on everyone's screen; what they're called |
nw(id, slot[, value]) | Eight numbers per person that every machine has (a class, a level, a lap time): handy in a hud hook |
voice(id, {hurt=, death=, chatter=}) | The sounds they make when hit and when killed (false: none); chatter=false silences a bot's callouts |
Reading works anywhere: eye(id) gives where their eyes are and the way they look, cansee(a, b), trace(x1,y1,z1, x2,y2,z2[, ignore]) against the map and people, and near(x, y, z, r[, team]) lists the living near a spot.
Driving bots (host only)
botcontrol(id, true) switches a bot's own judgement off and hands it to you, until you switch it back.
| Function | Does |
|---|---|
botgoto(id, x, y, z[, near]), botchase(id, target[, near]), botwander(id), botstop(id) | Walk the nav mesh to a spot, chase someone (straight at them once close and in sight), roam, or stand still. botarrived(id) says it got there. |
botmove(id, forward[, side]) | Steer it yourself, -1 to 1 each, relative to the way it faces |
botlook(id, target | x, y, z | {yaw=, pitch=} | "path"), botturn(id, degPerSec) | Where it looks, and how fast it turns |
botbuttons(id, {...}), botpress(id, name) | Buttons held, or pressed once: "attack", "jump", "crouch", "sprint", "use", "prone", "reload", "kick", "grab" |
botspeed(id, k) | How much of its legs it uses (0 to 1) |
Hooks that change what happens
Any mod can answer these events. The first mod that returns something decides.
| Event | Return |
|---|---|
orphan.on("damage", fn(victim, attacker, amount, weapon, headshot)) | false blocks the hit, a number replaces the damage, nothing lets it through |
orphan.on("chat", fn(id, text, teamOnly)) | false swallows the line (a chat command), a string rewrites it |
Your own sounds
Put .ogg, .wav, .mp3, .flac or .qoa files in your mod folder. local groan = orphan.game.snd("sounds/groan") gives you a name every machine can play. Files named groan_1, groan_2 and so on are picked from at random. Play it at someone with soundon(id, name[, volume]), at a spot with sound(name, x, y, z), or use it in voice. Players who join get the files with your mod.
Talking between machines: orphan.net
Messages carry up to eight numbers, strings or booleans. Handle them with orphan.on("net:" .. name, fn(sender, ...)). Clients are limited to a steady rate of messages.
| Function | Does |
|---|---|
orphan.net.send(id, name, ...) | Host: to one player's machine |
orphan.net.broadcast(name, ...) | Host: to everyone |
orphan.net.tohost(name, ...) | Any machine: to the host (the sender is your actor id) |
See mods/zombies (five kinds of zombie, all from Lua: crawlers, quadruped leapers, brutes, their own sounds), mods/surf (Source-style surfing with checkpoints, a timer and bots that surf) and mods/gang_wars (crews dressed by the mod) for complete examples.
Making a game mode
orphan.game.mode{...} registers a mode. Registering the same id again replaces it, which is how hot reload works: save the file and the mode updates.
orphan.game.mode{
id = "gangwars", name = "Gang Wars", desc = "One line for the menu",
teams = { "GROVE", "BLOODS" },
colors = { {38,128,52}, {168,28,34} }, -- shirts and HUD (false: the usual)
botNames = { { "Dre", "Tiny" }, { "Shadow", "Duke" } },
streetClothes = true,
hunters = true,
damage = 1.8,
roundTime = 240,
roundStart = function() end, -- host: before anyone spawns
spawn = function(id) end, -- host: hand out their kit
tick = function(dt) end, -- host: every tick of a live round
checkEnd = function() return nil end, -- host: a team, "draw", or nil (+ why)
death = function(victim, killer) end, -- host
banner = function(team) return "..." end, -- the line under GO GO GO
hud = function(w, h) end, -- every machine: draw with orphan.draw
}
| Field | Default | Meaning |
|---|---|---|
id | (required) | What mp_gamemode takes |
name, desc | the id, (none) | Shown in PLAY > GAME MODE |
teams | SWAT / SUSPECTS | The two sides' names |
colors | the usual | Each side's shirt and HUD colour; false keeps a side's usual look |
botNames | lobby names | What each side's bots are called |
streetClothes | false | Nobody in the SWAT uniform (team 3 still is); the bots talk like it |
oneSide | false | Everyone's on side 0; the mode brings the enemy (addbot on team 3) |
teamMax | {0, 0} | Most players on each side (0: no limit) |
hunters | false | Bots go looking for the other side instead of holding |
damage | 1 | Bullets hit this much harder |
roundTime, rounds | mp_roundtime, 0 | Seconds a round; rounds a match (0: the server's setting) |
respawn, respawnDelay | false, 5 | Players come back after this many seconds |
map | (any) | Only ever played on this map |
economy, shop, claimBoard, civilians, objectives, planning, debrief | false | Switch on the game's own systems: money, the shop, the role board, civilians, mission objectives, the planning phase, the debrief |
| Hook | Runs on | When |
|---|---|---|
matchStart() | host | A match on your mode starts |
roundStart() | host | Each round, before anyone spawns |
spawn(id) | host | Someone spawns: give them their kit |
tick(dt) | host | Every tick of a live round |
checkEnd() | host | Return a team (0 or 1), "draw", or nil to play on, plus a reason. Leave it out and a round ends when one side's out (the other wins) or the clock runs down (a draw). With it, those are up to you. |
death(victim, killer) | host | Someone died |
banner(team) | every machine | Return the line shown to that side under GO GO GO |
hud(w, h) | every machine | Draw on the HUD with orphan.draw; read shared state with get / text |
A complete mode: King of the Hill
Hold the marked spot for 60 seconds in total. It reads its hill from the map (a marker you place in Spade), keeps the score in shared slots, and shows it to everyone.
local G = orphan.game
local HILL, NEED = { x = 0, y = 0, z = 0 }, 60
G.mode{
id = "hill", name = "King of the Hill",
desc = "Hold the marked spot for 60 seconds in total.",
teams = { "SWAT", "CREW" }, roundTime = 300,
roundStart = function()
local spots = G.spawns("hill") -- a custom marker placed in Spade
if spots[1] then HILL = spots[1] end
G.set(1, 0); G.set(2, 0)
G.marker(1, HILL.x, HILL.y + 2, HILL.z, "HILL")
end,
tick = function(dt)
local on = { [0] = 0, [1] = 0 }
for _, id in ipairs(G.players()) do
local a = G.actor(id)
if a and a.alive and a.team <= 1 and (a.x - HILL.x)^2 + (a.z - HILL.z)^2 < 9 then on[a.team] = on[a.team] + 1 end
end
if on[0] > 0 and on[1] == 0 then G.set(1, G.get(1) + dt) end
if on[1] > 0 and on[0] == 0 then G.set(2, G.get(2) + dt) end
G.objective(string.format("HILL SWAT %d s CREW %d s (first to %d)", G.get(1), G.get(2), NEED))
end,
checkEnd = function() -- with checkEnd, wipes and the clock are ours to handle
if G.get(1) >= NEED then return 0, "SWAT held the hill" end
if G.get(2) >= NEED then return 1, "The crew held the hill" end
if G.alive(0) == 0 then return 1, "SWAT are down" end
if G.alive(1) == 0 then return 0, "The crew are down" end
if G.roundtime() <= 0 then return "draw", "Nobody held it long enough" end
end,
}
name = King of the Hill
version = 1.0
entry = main.lua
enabled = 1
api_version = 1
network = mode # players need it while this mode is played
Maps for your mode
Your mode can be played on any map, and spawns(class) reads any marker the map has. To give a map spots of its own for your mode, open it in Spade, drop a Custom marker (Toolbox > Gameplay) where you want it, and set its Class in Properties to a name of your choosing, like hill. Every marker of that class comes back from orphan.game.spawns("hill") as {x, y, z, yaw}. The map's own markers work too: spawn_swat, spawn_suspect, info_breaker and the rest (Spade's guide lists them all).
To tie a mode to one map, set map = "my_map" in it. Other modes then won't be offered that map.
Events and vetoes
The game tells mods what happens in a match, and asks them before some things happen. Listen with orphan.on as usual. Most come only on the host, the machine running the match (chapter 14), because that's where it happens.
Vetoes and changes
For these the game asks: the first handler (in load order) that returns something decides. Return nothing to let it be.
| Event | Arguments | Return |
|---|---|---|
damage | victim, attacker, amount, weapon, headshot | false stops the hit; a number is the damage it does instead |
chat | id, text, teamOnly | false swallows the line; a string replaces it |
fire_bullet | shooter, weaponId, weapon | false: no round leaves the gun |
can_pickup | actor, item, name, kind | false: they can't take it |
can_arrest | victim, by | false: the cuffs don't go on |
can_respawn | actor, deadFor | false: they wait (asked again later) |
can_use | ent, actor | false: they can't use that entity |
-- friendly fire off, headshots double, and no shooting in the first 10 seconds
local G = orphan.game
local started = 0
orphan.on("round_start", function() started = G.time() end)
orphan.on("damage", function(victim, attacker, amount, weapon, headshot)
local v, a = G.actor(victim), G.actor(attacker)
if v and a and victim ~= attacker and v.team == a.team then return false end
if headshot then return amount * 2 end
end)
orphan.on("fire_bullet", function(shooter)
if G.time() - started < 10 then G.notice("Not yet!", shooter) return false end
end)
What happened
| Event | Arguments | When |
|---|---|---|
round_start, round_end | n / winner, why | A round started / ended (winner -1: a draw) |
spawn, death | id, team / victim, killer | Someone (re)spawned / died (killer -1: nobody) |
player_join, player_leave | actor, name | A person (not a bot) came in / went |
wounded | actor, wounds | A new wound (how many they have now) |
bone_break | actor, bone | skull, jaw, ribs, pelvis, left arm, right arm, left leg, right leg |
limb_lost, dislocated | actor, limb | Shot or blown off / out of its socket |
knocked_out, woke_up | actor | Out cold / came round |
went_down, got_up | actor | On the floor as a ragdoll / up again |
heart_stopped, heart_started | actor | Cardiac arrest / back |
stopped_breathing, breathing | actor | The lungs stopped / started |
seizure, arrested | actor | A fit started / cuffed |
drug_dose | actor, drug, doses | They took something (chapter 12) |
treat | by, patient, item | A medical item used on someone |
item_pickup | actor, name | They took something |
weapon_switch | actor, name | What's in their hands changed |
weapon_deploy | actor, name | A scripted weapon came out |
vehicle_enter, vehicle_exit | actor, vehicle | Got in / out of a car |
noise | x, y, z, radius, kind, source | A shot, a bang or a voice the bots can hear |
explosion | x, y, z, power, owner | Something went off |
glass_break | pane, x, y, z, gone, by | A window hit (gone: it fell out) |
door_breach | door, blown | A door forced (blown: by a charge) |
wall_blast, wall_hit | x, y, z, power / brush, x, y, z, how | The building breaking |
trigger_enter, trigger_leave | id, event | Someone stepped into / out of one of the map's trigger parts |
entity_created, entity_removed | id, class | An entity was spawned / is gone |
entity_damage | id, amount, attacker, kind | An entity took a hit |
case_ready | caseNo | The round's scene has been worked: orphan.forensics.case() has it |
net:<name> | sender, ... | A message from another machine (chapter 14) |
ui_escape | (none) | Esc while a mod has the mouse (chapter 11) |
Scripted entities and physics
orphan.ents is the game's version of scripted entities: things in the world with a look, real physics (the same engine as the ragdolls and props) and Lua hooks. The host spawns and runs them; every machine sees them move, interpolated from the snapshots. A class is a table; an entity is a table too, with its class behind it, so methods and fields work as you'd expect.
local ents = orphan.ents
ents.define("bounce_pad", {
printName = "Bounce pad", category = "Fun", spawnable = true, -- spawnable: in the Sandbox's spawn menu
shape = "box", size = { 0.6, 0.05, 0.6 }, mass = 30, color = { 40, 200, 120 },
init = function(self) self:freeze(true) self.power = 9 end,
touch = function(self, other, kind)
if kind == "actor" then orphan.game.push(other, 0, self.power, 0) end
end,
use = function(self, actor) self.power = self.power == 9 and 16 or 9 self:nw("power", self.power) end,
damage = function(self, amount, attacker) if amount < 20 then return false end end, -- shrugs off small hits
})
orphan.command("pad", "A bounce pad in front of you", function()
local me = orphan.game.me()
local x, y, z, dx, dy, dz = orphan.game.eye(me)
local hit = orphan.phys.trace(x, y, z, x + dx * 5, y + dy * 5, z + dz * 5, me)
if hit.hit then ents.spawn("bounce_pad", hit.x, hit.y + 0.05, hit.z) end
end)
Classes
ents.define(name, class) takes the look (shape box, sphere, capsule, cylinder or model with a file from your folder, size, color, light), the physics (mass, health) and the hooks. base names another class, from any mod, to build on: what you don't say comes from it.
| Hook | Runs | Gets |
|---|---|---|
init | Host, when it's spawned | self |
think | Host, every tick | self, dt |
use | Host, someone pressed E on it | self, actor |
touch | Host, it hit or was hit by something | self, other, kind (kind: world, ent, prop, ragdoll, item, actor) |
damage | Host, a hit | self, amount, attacker, kind, x, y, z; return false to ignore it |
remove | Host, it's going | self |
clientInit, clientThink | Every machine, when it first sees it / every frame | self, self, dt |
draw | Every machine, the 3D pass | self (draw extra things with orphan.draw3d) |
Any other function in the class is a method you can call on the entity (self:light_fuse()), and ent:call("hook") runs one by name, which is how another mod can trigger yours.
Entities
ents.spawn(class, x, y, z [, opts]) returns the entity (opts: angles, yaw, rot, size, color, frozen, owner, and any field you like, copied onto it). Find them again with ents.get(id), all(), find(class [, derived]) or near(x, y, z, radius).
| Method | Does |
|---|---|
pos, setPos, angles, setAngles, rot, setRot | Where it is and how it's turned (degrees or a quaternion) |
forward, right, up, toWorld, toLocal | Its own axes, and points between its frame and the world's |
vel, setVel, angvel, setAngVel | How it's moving |
push, force, torque | An impulse, a force for this step, a turning force (optionally at a point) |
mass, shape, model | Read or change them |
freeze, nocollide, nodraw, glow, nogravity, trigger, kinematic | Switches (a trigger passes through things and only calls touch) |
health, owner, light | What it takes to break it, whose it is, the light it gives off |
nw(key [, value]) | A value every mod and every machine sees: 8 keys, numbers, booleans or short text. The host sets them. |
sound, remove, id, class, valid | The rest |
A field can't have a method's name: self.force = 5 is an error, because it would hide self:force(). Call yours self.thrust.
Physics: orphan.phys
These take a ref: anything physical. An entity, a prop's or ragdoll part's ref from a trace (hit.ref), or "world" for the map.
| Function | Does |
|---|---|
trace(x1,y1,z1, x2,y2,z2 [, ignoreActor]) | A ray against everything: hit, the point and normal, fraction, kind, and ent / actor / ref for what it hit |
pose, valid, mass | Where it is (and its rotation), still there, how heavy |
freeze(ref [, on]), gravity(ref, on) | Hold it still (a ragdoll's limb too: posing), gravity on it or not |
drive(ref, local point, target [, opts]) | Pull a point of it to a target, every tick you call it. The physics gun is this. |
constrain(kind, refA, pointA, refB, pointB [, opts]) | weld, rope, elastic, ballsocket, axis, slider, nocollide, motor or winch between two things. Returns an id. |
conset(id, what, value) | Change a motor's speed or torque, a winch or rope's length, a constraint's strength as it runs |
-- a box on a 2 m rope from the ceiling above you, swinging
local x, y, z = orphan.game.eye(orphan.game.me())
local up = orphan.phys.trace(x, y, z, x, y + 10, z)
local box = orphan.ents.spawn("bounce_pad", x + 1, y, z)
if up.hit then
orphan.phys.constrain("rope", box, 0, 0.05, 0, "world", up.x, up.y, up.z, { length = 2 })
end
Up to 256 entities and 128 ropes and cables are in the world at once.
Scripted weapons
Two kinds. A scripted tool is held in the hands like the multitool and does whatever your Lua says; its hooks run on the host as the buttons go down and up, and it draws itself (boxes, or a draw hook) on every machine. A scripted gun is a real gun of the game's (rounds, ballistics, recoil, wall penetration) with your numbers.
orphan.weapons.define("pad_launcher", {
printName = "Pad launcher", category = "Fun", spawnable = true,
hint = "LMB: throw a bounce pad R: take them all back",
delay = 0.5, -- seconds between primaries
model = { { c = { 0, 0, 0.1 }, s = { 0.05, 0.05, 0.25 }, color = "lime" } }, -- boxes in the hand
nw = { "count" }, -- numbers every machine sees
primary = function(self)
local id = self:owner()
local x, y, z, dx, dy, dz = orphan.game.eye(id)
local pad = orphan.ents.spawn("bounce_pad", x + dx, y + dy, z + dz, { owner = id })
if pad then pad:freeze(false) pad:push(dx * 60, dy * 60 + 20, dz * 60) end
self.n = (self.n or 0) + 1
self:nw("count", self.n)
end,
reload = function(self)
for _, e in ipairs(orphan.ents.find("bounce_pad")) do if e:owner() == self:owner() then e:remove() end end
self.n = 0 self:nw("count", 0)
end,
})
orphan.on("spawn", function(id) orphan.weapons.give(id, "pad_launcher") end)
Hooks: primary, secondary, reload (pressed), primaryRelease, secondaryRelease, think(self, dt), deploy, holster, and draw / hud(w, h) on the machine holding it. automatic = true repeats primary every delay while the button is held. self:owner() is who holds it and self:nw(key [, value]) shares a number.
weapons.give(actor, name [, select]), strip, has, select, active, carried and list manage them. give_sweps in the console (cheats on) gives you every one.
Guns of your own
-- a heavy-barrel M4: slower, harder hitting, a 20-round magazine
local id = orphan.weapons.gun("m4_heavy", {
base = "m4a1", printName = "M4 Heavy", damage = 48, rpm = 600, magSize = 20,
modes = { "semi", "auto" }, recoilUp = 1.4, color = { 60, 60, 66 },
})
orphan.on("spawn", function(actor) orphan.game.give(actor, "m4_heavy", 4) end)
Drawing in the world
2D drawing (orphan.draw) works in the draw event. 3D drawing works in the draw3d event, in the world with depth and lighting, on every machine: use it for markers, beams, holograms, screens on walls.
local d3 = orphan.draw3d
orphan.on("draw3d", function()
for _, e in ipairs(orphan.ents.find("bounce_pad")) do
local x, y, z = e:pos()
d3.beam(x, y, z, x, y + 2, z, 0.05, { 40, 255, 140, 120 })
d3.text("x" .. (e:nw("power") or 9), x, y + 2.2, z, 0.25, "lime")
end
-- a screen on a wall: draw on it in pixels with orphan.draw, 1 px = 1 cm
d3.panel(4, 2, -3, { 0, 90, 0 }, 0.01, function()
orphan.draw.rect(0, 0, 200, 80, { 0, 0, 0, 200 })
orphan.draw.text("PADS: " .. #orphan.ents.find("bounce_pad"), 10, 25, 30, "lime")
end)
end)
| Function | Draws |
|---|---|
line, beam, cylinder, capsule | Lines; flat strips that face you (lasers); cylinders and cones; capsules |
text(text, x,y,z [, size [, color]]) | Text that faces you, size metres tall |
panel(x,y,z, {p,y,r}, scale, fn) | A flat 2D surface: fn draws on it with orphan.draw.* |
model(path, x,y,z [, opts]) | A model file from your folder (obj, gltf, glb, iqm) |
nodepth(fn) | What fn draws shows through walls |
toScreen(x,y,z), camera() | A point on the screen (for HUD markers), where the view is |
Effects: orphan.fx
particle(kind, x,y,z, vx,vy,vz [, life, size, color]) with kinds dust, blood, spark, smoke, debris, fire, water, flash and cloud; sparks, puff, blood, decal (a mark on a surface), light (a light that fades), shake. These happen on the machine that calls them. fx.all(name, ...) on the host plays one on every machine, and fx.explosion(x, y, z [, power [, owner]]) is a real one: it hurts, throws things and breaks walls.
Windows and widgets
orphan.ui is a widget toolkit: windows you can drag and resize, tabs, buttons, sliders, text boxes, lists, grids. It draws in the game's skin (or your own: ui.skin). A popup window takes the mouse and keyboard from the game while it's open, and Esc closes it.
local ui = orphan.ui
local win
orphan.command("pads", "The bounce pad settings", function()
if win then win:remove() win = nil return end
win = ui.window("Bounce pads", 320, 220, { popup = true })
local power = ui.create("slider", win); power:dock("top", 4)
power:setText("Power"); power:setRange(2, 30, 0); power:setValue(9)
local glow = ui.create("check", win); glow:dock("top", 4); glow:setText("Glow")
local go = ui.create("button", win); go:dock("bottom", 4); go:setText("Apply to all")
function go:onClick()
orphan.net.tohost("pads", power.value, glow.checked)
ui.notify("Done", 2, "lime")
end
end)
Widgets: panel, label, button, check, slider, entry, list, tabs, scroll, grid, combo, color, image, progress. Lay them out with dock("top"|"bottom"|"left"|"right"|"fill", margin) or setPos / setSize; react with onClick and onChange(self, value), or read slider.value, check.checked, entry:getText(). tabs:addTab(name) returns the page to fill, list:addRow, combo:addOption.
Taking the mouse and keyboard
| Function | Does |
|---|---|
ui.capture(on) | This mod has the mouse and keyboard: the game stops looking, walking and shooting. Three Esc presses always give them back. |
ui.chars(), ui.keyRepeat(key), ui.clipboard([text]) | Typing: what was typed this frame, held keys repeating, the clipboard |
ui.lookLock(on), ui.mouseDelta() | The mouse's moves come to you instead of turning the view (turning a held prop) |
ui.wheelGrab(on) | The wheel is yours: it doesn't change weapons |
ui.cursor(shape) | arrow, ibeam, hand, resize or move |
The body, drugs and forensics
Every person in SPASTAZ runs a medical simulation: blood, oxygen, pulse, organs, bones, pain, shock, drugs. orphan.body reads it on any machine and changes it on the host.
-- a drug of your own: it slows you down, and bullets do a fifth of the damage while it lasts
orphan.body.defineDrug("ironskin", {
halfLife = 30, onset = 2,
dose = function(id, n) orphan.game.notice("You feel heavy.", id) end,
tick = function(id, level, dt) orphan.game.movement(id, { speed = 1 - math.min(level, 1) * 0.4 }) end,
wornOff = function(id) orphan.game.movement(id, { speed = 1 }) end,
})
orphan.on("damage", function(victim, attacker, amount)
if orphan.body.level(victim, "ironskin") > 0.5 then return amount * 0.2 end
end)
orphan.command("ironskin", "Take a dose", function() orphan.body.dose(orphan.game.me(), "ironskin") end)
| Function | Does |
|---|---|
body.get(id) | Everything: blood, bleeding, o2, pulse, conscious, pain, shock, bones, limbs, organs, drugs, wounds, a one-word condition |
body.set(id, {...}) | Host: blood, o2, pain, shock, stamina, adrenaline, analgesia, concussion... |
body.breakBone(id, bone), body.organ(id, organ [, damage]) | Host: break a bone, read or set an organ's damage |
body.dose(id, drug [, n]), body.level(id, drug) | Host: give doses (the game's drugs, body.drugs(), or yours); how much is in their blood |
body.defineDrug(name, def) | A drug whose effects are your Lua: dose, tick(id, level, dt), wornOff |
forensics.case() | After the round (event case_ready): the case file: casings, shots, and per person the cause of death, findings, toxicology and charges |
Doors, power and weather
| Function | Does |
|---|---|
world.doors() | Every door: id, where, shut, locked, broken, gone, wedged |
world.door(id, action) | Host: open, shut, toggle, lock, unlock, breach or blow it |
world.blackout() | Host: the fuse box goes: the lights are out |
world.weather([name]) | clear, overcast, drizzle, rain, storm, fog, snow, blizzard, windy, heat (setting: host; everyone follows) |
world.timeofday([name]) | day, dusk, night, or map: what its maker set |
world.material(x1,y1,z1, x2,y2,z2) | What the first wall along a line is made of, and how much harder than drywall it is to shoot through |
-- lights out and a storm when the last suspect is left
orphan.on("death", function()
if orphan.game.alive(1) == 1 then
orphan.world.blackout()
orphan.world.weather("storm")
for _, d in ipairs(orphan.world.doors()) do if not d.gone then orphan.world.door(d.id, "lock") end end
end
end)
Maps can call your mod too: a trigger part in Spade with an Event sends trigger_enter / trigger_leave with that event name, and orphan.game.armtrigger(event, on) switches them.
Realms and networking
In a match one machine is the host (offline, that's you; online, the player hosting or the dedicated server) and the rest are clients. Every machine runs your mod. The host runs the match: the functions marked "Host" only do something there, and the events about the match only come there. Clients draw, play sounds, show menus and send the host what their player wants.
| Function | True on |
|---|---|
orphan.server() | The host (it runs the match) |
orphan.client() | Any machine with a screen and a player (the listen host too) |
orphan.dedicated() | A dedicated server: no screen, no local player |
Files by realm
Name files sv_*.lua (host), cl_*.lua (clients) or sh_*.lua (both) and load them with orphan.include: cl_ files aren't loaded on a dedicated server, and with private = sv_ in mod.cfg the server never sends its sv_ files to players, so your server code stays yours. include returns nil where a file isn't loaded.
Messages
-- client: ask the host for something
orphan.net.tohost("pads", 12, true)
-- host: check what came in (n number, s string, b boolean, ? anything; upper case may be nil)
orphan.net.receive("pads", "nb", function(sender, power, glow)
power = math.max(2, math.min(30, power))
for _, e in ipairs(orphan.ents.find("bounce_pad")) do
if e:owner() == sender then e.power = power e:glow(glow) end
end
orphan.net.send(sender, "pads_done", power)
end)
-- client: the answer
orphan.on("net:pads_done", function(sender, power) orphan.ui.notify("Pads at " .. power) end)
net.send(id, name, ...) and net.broadcast(name, ...) go from the host; net.tohost from anywhere (on the host itself it arrives straight away, from your own player). Up to 8 arguments: numbers, booleans and strings up to 1024 characters. A client may send 120 messages a second; past that the host drops them. Everything a client sends is a request: check it on the host, as above.
Cameras and films
orphan.camera takes this machine's view: for cutscenes, kill cams, security monitors, or films. It stays yours until you release it, and three Esc presses always give it back.
local cam = orphan.camera
-- a slow circle round the bomb, no HUD, for 6 seconds
orphan.on("explosion", function(x, y, z)
cam.hud(false)
cam.orbit(x, y, z, 6, 2.5, 30)
orphan.after(6, function() cam.release() cam.hud(true) end)
end)
set(x,y,z, tx,ty,tz [, fov [, roll]]) puts the view anywhere, follow(actor or ent [, back [, up]]) rides behind something, orbit circles, view() says where it is now, release() gives the view back, and hud(on) hides everything for a clean shot. For moving shots, path({...}) makes a smooth path from keyframes, play(path [, {loop=, onEnd=}]) flies the view along it, record() / stopRecording() turn what you see into a path, and save / load keep paths in your folder. The shipped Director mod (F7) is built on these: record what you see and play it back, dolly shots from keyframes, a letterbox. Read mods/director to see how.
The Sandbox mode
Sandbox is a game mode that ships as a mod (src/mods/sandbox), written with nothing but the API in this guide. Pick it in PLAY > GAME MODE, or mp_gamemode sandbox. Any map works, and it never ends.
| Key | Does |
|---|---|
Q | The spawn menu: props, entities, weapons, the tool gun's tools and their settings |
5 | Your physics gun and tool gun |
| Physics gun | LMB grab, RMB freeze, R unfreeze (hold R and move the mouse to turn it), wheel nearer / further |
| Tool gun | LMB / RMB / R do what the tool says on the HUD: weld, rope, elastic, axis, ballsocket, slider, nocollide, winch, motor, wheel, thruster, hover ball, balloon, lamp, button, camera, sign, dynamite, colour, material, mass, remover, duplicator |
Backspace, N | Undo the last thing you made, noclip |
| Numpad | Thrusters, wheels, winches, buttons and cameras answer the keys you gave them |
Making addons for it
Anything you mark spawnable = true, in any mod, shows up in the Sandbox spawn menu under its category: entity classes on the Entities tab, scripted weapons and guns on the Weapons tab. So a Sandbox addon is just a mod with entities or weapons in it. The bounce pad and pad launcher from chapters 8 and 9 are one already. Put requires = sandbox in it only if you use something of the Sandbox's own, like its sbox_base class (which makes things ownable, undoable and copyable by the duplicator):
orphan.ents.define("hover_pad", {
base = "sbox_base", printName = "Hover pad", category = "Hover Pads", spawnable = true,
shape = "box", size = { 0.5, 0.04, 0.5 }, mass = 15, color = { 90, 90, 255 },
think = function(self, dt)
local x, y, z = self:pos()
for _, e in ipairs(orphan.ents.near(x, y + 1, z, 1)) do
if e ~= self then e:force(0, e:mass() * 12, 0) end -- holds them up
end
end,
})
Share it on the workshop's Sandbox addons board: players install it from MODS > WORKSHOP, and servers send it to anyone joining.
Maps with their own Lua
A map can carry its own scripts inside its file: a button that floods the basement, a boss in the attic, a puzzle door. They travel with the map, so everyone who plays it has them. When the map loads they run as a mod named @map, on the host and on every client, starting at main.lua. They're always sandboxed: no console commands, no settings but their own, nothing outside their folder.
- Write them like any mod, in a folder (say
mods/my_map_scripts/) with amain.lua. Useorphan.game.map(), the map's markers (orphan.game.spawns("my_marker")) and its trigger events. - In the console:
maplua_pack my_map mods/my_map_scripts. That puts them intoassets/maps/my_map.mbsptz. Add a name at the end (maplua_pack my_map mods/my_map_scripts my_map_v2) to write a copy instead. - Load the map. The console says its scripts are running, or why not.
-- main.lua in a map: the vault door opens when someone reaches the switch, and the alarm goes
local G = orphan.game
orphan.on("trigger_enter", function(id, event)
if event ~= "vault_switch" or not orphan.server() then return end
for _, d in ipairs(orphan.world.doors()) do
local x, y, z = d.x, d.y, d.z
if x > 10 and z < -4 then orphan.world.door(d.id, "open") end -- the vault's doors
end
G.banner("ALARM", "The vault is open")
end)
maplua_unpack my_map some/folder gets them out again to work on, and maplua_strip my_map takes them out of the map. Spade keeps them when it saves the map. Only .lua, mod.cfg, text, json, png, jpg, wav, ogg and mp3 files go in (4 MB at most), and of a mod.cfg only name and requires count. Players who'd rather not run map scripts set mod_maplua 0.
OrphanASM
OrphanASM is a small, safe assembly language that runs in a virtual machine inside the engine. Every memory access is bounds-checked, so a bad pointer stops your program with a clear message ("trap") instead of crashing the game.
The machine
- Registers:
r0–r15(32-bit integers;r14is alsofp,r15issp) andf0–f7(32-bit floats). - Flags: Z (zero/equal), N (negative/less), C (unsigned less), set by
cmp,testandfcmp. - Memory: 64 KB by default, up to 4 MB with
.memory. Addresses below 16 trap (a null-pointer guard), your.datastarts at 16, and the stack grows down from the top. - Calls:
call/ret, up to 256 deep. - Budget: 2,000,000 instructions per call by default (
mod_asm_budget). Useyieldto spread long work over several frames.
Entry points
| Label | Runs | Gets |
|---|---|---|
init: | Once, when the mod loads | (nothing) |
update: | Every frame | f0 = frame time in seconds |
draw: | Every frame; the only place draw syscalls work | (nothing) |
on_<event>: | When that event fires, e.g. on_round_start: | Whole-number arguments in r1–r6, others in f0–f3 |
on_unload: | Before unload or reload | (nothing) |
main: | Only for asm_run and the editor preview | (nothing) |
Each entry point ends with ret. halt stops the program for good.
A complete mod
.data
hello: .string "hello from mover\n"
x: .float 40
speed: .float 120
.text
init:
mov r1, hello
sys print_str
ret
update: ; f0 = dt
fld f1, [x]
fld f2, [speed]
fmul f2, f0 ; speed * dt
fadd f1, f2
sys screen_w
itof f3, r0
fsub f3, 60.0
fcmp f1, f3
jl .store
fmov f1, 40.0 ; wrap around
.store:
fst [x], f1
ret
draw:
fld f0, [x]
fmov f1, 60.0
fmov f2, 20.0
fmov f3, 20.0
mov r1, 0x46A0FFFF ; colour 0xRRGGBBAA
sys draw_rect
ret
Labels that start with a dot, like .store, are local to the label above them, so every function can have its own .loop.
Directives
| Directive | Example | Does |
|---|---|---|
.text / .data | Switch between the code and data sections | |
.word | .word 1, label, -3 | 32-bit integers |
.byte | .byte 1, 'a', "text" | Bytes; strings expand to characters |
.float | .float 1.5, 0.25 | 32-bit floats |
.string | .string "hi\n" | Zero-terminated string (also .asciz). .ascii leaves off the zero. |
.space | .space 64 | Zeroed bytes (also .zero) |
.align | .align 4 | Pad data to a multiple |
.equ | .equ MAX, 42 or MAX = 42 | Named constant |
.memory | .memory 131072 | Total VM memory in bytes (max 4 MB) |
.include | .include "lib.oasm" | Paste another file from your mod folder |
.macro / .endm | .macro PRINT_INT v | Macros. \v is an argument, \@ a unique number. |
Instructions
Operands are registers, constants, labels (their address), or memory as [label], [reg] or [reg + offset]. Arithmetic has two forms: add r1, r2 means r1 += r2, and add r1, r2, r3 means r1 = r2 + r3.
| Group | Instructions |
|---|---|
| Move | mov lea xchg |
| Integer maths | add sub mul div mod divu modu min max inc dec neg abs. Dividing by zero traps. |
| Bits | and or xor not shl shr sar |
| Compare | cmp test fcmp |
| Memory | ld st (32-bit), ldb stb (byte), fld fst (float), push pop pushf popf |
| Jumps | jmp jz/je jnz/jne jl jle jg jge (signed) jb jbe ja jae (unsigned) loop |
| Calls | call ret sys |
| Float maths | fmov fadd fsub fmul fdiv fmin fmax fsqrt fabs fneg fsin fcos ffloor itof ftoi |
| Control | nop yield (continue next frame) brk (breakpoint in code) halt |
Syscalls
sys name calls the engine. Arguments go in r1–r6 and f0–f3, results come back in r0 / f0. Strings are addresses of zero-terminated text. asm_syscalls in the console lists them all with their registers.
| Group | Syscalls |
|---|---|
| Output | print print_hex print_f print_str print_char newline |
| Memory and text | memcpy memset strlen strcmp itoa ftoa atoi |
| Maths | rand randf seed atan2 pow lerp clampf |
| Engine | exec cvar_get cvar_set time dt frame fps screen_w screen_h cheats emit |
| Input | key (name → code) key_down key_pressed mouse mouse_down |
| Drawing | draw_rect draw_rect_lines draw_line draw_circle draw_text draw_int text_width. Colours are 0xRRGGBBAA in r1. |
| Textures and sound | tex_load tex_size draw_tex draw_sprite sound_load sound_play sound_stop music_play music_stop |
From the console: asm_check file assembles and lists errors, asm_run file [label] runs main: once, and asm_disasm file shows what the assembler made. Paths are relative to mods/.
The MODS screen and mod settings
Main menu → MODS, or menu_mods in the console. The left list shows every installed mod with a status light: green running, orange waiting for a requirement or has a warning, red error, grey off. The switch turns a mod on or off and saves that in its mod.cfg.
The detail page for the selected mod shows:
- its description, version, author, kind and API level,
- its state, with the full error or warning if there is one,
- REQUIRES and NEEDED BY: which mods it depends on and which depend on it,
- SETTINGS: every setting the mod made, as a switch, slider or −/+ control,
- STATS: time per frame, Lua memory, tasks, textures, sounds, reload count,
- buttons: ENABLE/DISABLE, RELOAD, OPEN FOLDER, EDIT (opens the mod in Orphan Editor).
At the bottom: RESCAN finds new mod folders, NEW MOD creates a working Lua mod, and OPEN MODS FOLDER opens Explorer.
The workshop tab
The WORKSHOP tab lists uploads from the workshop: mods, weapons and gear, Sandbox addons, maps. You can search them and sort by newest, top rated, most downloaded or recently updated. INSTALL downloads the upload's .zip, checks it and unpacks it into your mods folder. A new install starts switched off and sandboxed until you enable it, and UPDATE appears when the upload has a newer version. To be installable from the game, an upload must link straight to a .zip with mod.cfg at its top or inside one folder.
Settings in detail
local on = orphan.newcvar("radar_on", true, "Show the radar") -- switch
local zoom = orphan.newcvar("radar_zoom", 1.0, "Radar zoom", 0.5, 3.0) -- slider
local blips = orphan.newcvar("radar_blips", 8, "Max blips") -- - / + buttons
- The help text becomes the label on the MODS screen. Name settings with your mod's prefix so they don't collide with anyone else's.
- Settings are saved in
config.cfg. They keep their value while the mod is off, and come back when it's on again. - Changes from the MODS screen or the console fire your
cvarevent. Update your local copy there. - Up to 32 settings and 32 commands per mod.
Debugging
In the editor preview
Press F5 on a .lua or .oasm file and it runs in the preview panel. For anything about the match (orphan.game, entities, weapons), run it in the game instead: the editor attaches to it (below). Click the preview to send it your keyboard and mouse.
- Breakpoints: click left of the line numbers or press
F9. The run pauses there, and the DEBUG tab shows the call stack, locals, upvalues (marked^), watch expressions and globals. - Stepping:
F10over,F11into,Shift+F11out,F5continue,F6pause at the next line that runs. - ASM: the same keys, plus registers (changes highlighted), flags, disassembly around the current line, the call stack, a memory viewer (type an address, a label or
sp) and all data labels. - Problems as you type: Lua syntax errors, ASM assembly errors,
mod.cfgmistakes (unknown keys, missing entry file, requirements that don't exist) all underlined in place.
Inside the running game
The game listens for Orphan Editor on 127.0.0.1:27960. That address only accepts connections from the same PC; live_link 0 turns it off. When the game runs, the editor attaches within a couple of seconds and the status bar shows SPASTAZ ATTACHED.
- Console mirror: the game's console appears in the editor's console as
[game]lines. - Send commands: click to: editor in the console header (or the status bar) to switch input to the game, then type any console command.
- Instant reload: saving a mod file reloads that mod in the game straight away.
- Breakpoints in the game: set one in a mod's file and that mod pauses inside the game when it gets there. The game keeps running and only your mod waits. The editor jumps to the line and shows the stack, locals, upvalues and globals; watches are evaluated in the game.
F10/F11step,F5continues. - Pause on demand:
F6with a mod file open pauses that mod at the next line it runs. - MODS panel: in the editor's right panel it shows each mod's state in the game, and its switches apply there too.
Closing the editor removes its breakpoints, and any mod it had paused carries on by itself.
Testing without a screen
The dedicated server can play a match with nobody in it and let your mod decide whether it passed. It's how the game tests its own mod API (tests/luatest/, run them all with tests\run_lua_tests.ps1):
spastaz_dedicated.exe --luatest my_mod warehouse 1500 mp_gamemode sandbox
That loads my_mod (a comma list loads several), starts warehouse, runs at most 1500 ticks (60 a second) with the settings after it, and stops when your mod says how it went:
orphan.task(function()
orphan.wait(0.5)
local bot = orphan.game.addbot(0, "Tester")
orphan.weapons.give(bot, "pad_launcher", true)
orphan.wait(1)
local ok = orphan.weapons.active(bot) == "pad_launcher"
orphan.setcvar("luatest_result", ok and 1 or 2) -- 1 passed, 2 failed
end)
Bots can be driven from Lua (orphan.game.botcontrol, botpress, botbuttons, botgoto...) to press buttons and walk about for you.
Handy console commands while debugging
| Command | Does |
|---|---|
mod_info <mod> | Everything about one mod: requirements, content, settings, memory, last error |
lua <code> | Run Lua right now; expressions print their value (lua orphan.game.map()) |
mod_emit <event> [args] | Fire an event by hand to test your handler |
mod_reload <mod> | Reload without saving a file |
Limits, the sandbox and servers
These keep one bad mod from spoiling the game. The numbers with a console variable can be changed by the player.
| Limit | Default | What happens |
|---|---|---|
Time per Lua call (mod_lua_budget) | 100 ms | The call stops with "script ran for over 100 ms". Use orphan.wait to split long work. |
Lua memory per mod (mod_lua_mem) | 64 MB | Allocations fail with "not enough memory" |
Instructions per ASM call (mod_asm_budget) | 2,000,000 | The program traps. Use yield. |
| Errors in a row | 10 in 2 s | The mod is switched off until it's reloaded (save a file or mod_reload) |
| Tasks per mod | 128 | Extra tasks are dropped with an error |
| Settings / commands per mod | 32 / 32 | Error when you make one more |
| Textures per mod | 64 (256 MB) | gfx.load returns nil and a reason |
| Sounds per mod | 64 | sound.load returns nil and a reason |
| ASM memory | 64 KB (max 4 MB) | Set with .memory; out-of-range access traps |
| Installed mods | 64 | Further folders are ignored |
| Entities / ropes and cables in the world | 256 / 128 | ents.spawn returns nil and a reason |
| Scripted weapons | 64 | weapons.define errors |
| Net messages from one client | 120 a second | The host drops the rest. 8 arguments, strings up to 1024 characters. |
| Map scripts | 256 files, 4 MB | The map's scripts don't run; the console says why |
File access is limited to your own mod folder, for Lua and ASM alike. Cheats (cheat cvars, give_sweps and so on) need the player to type sv_cheats 1 first. A mod can't turn cheats on for them.
Joining a server that runs mods
When you join, your game names its running mods and a fingerprint of each one's files. The server checks you have every mod it needs (its network setting, chapter 4) and that your copy is the same as its own. If you're missing one, or have a different copy, you're shown what's needed and how big it is:
- DOWNLOAD AND JOIN: the server sends the files and they're checked against its fingerprint. The mod goes into
mods/, gets switched on, and you join again by yourself. A different copy you already had is kept inmods/.backup/. - CANCEL: you don't join.
The console commands mod_download and mod_decline do the same. A copy you already have but had switched off is just switched on. Downloads are capped at 64 MB, and files can't land outside the mod's own folder.
Downloaded mods are sandboxed
A mod that came from a server, and a map's own scripts, can't:
- run console commands (
orphan.exec,orphan.game.exec, the ASMexecsyscall); - change any setting except the ones it made itself with
orphan.newcvar; - write more than 64 MB.
The console says so once when it tries. The list of sandboxed mods is mods/.sandboxed, which is outside every mod's folder, so a mod can't take itself off it. The MODS screen marks these mods DOWNLOADED FROM A SERVER. mod_trust <mod> lifts the sandbox, and mod_sandbox <mod> puts it back.
Troubleshooting
| You see | Why, and the fix |
|---|---|
| "orphan.draw.* only works inside draw" | You drew from update, load or top-level code. Move it into orphan.on("draw", ...). |
| "requires 'x', which isn't installed" | A folder named in requires doesn't exist. Check the spelling: it's the folder name, not the display name. |
| "requires 'x', which is disabled" (orange light) | Enable it, or just enable your mod from the MODS screen: that enables its requirements too. |
| "made for mod API 2, this engine only knows API 1" | The mod needs a newer SPASTAZ. Update the game. |
| "entry file 'main.lua' not found" | Fix entry in mod.cfg. |
| "'force' is a method: call the field something else" | An entity field can't hide one of its methods (chapter 8). Rename the field. |
A function does nothing and returns false | It's a "Host" function and this machine isn't the host. Do it in the host's code, or send the host a message. |
| The game stopped responding to the mouse | A mod has it (ui.capture, a popup window). Press Esc three times to take it back, then fix the mod. |
| "its scripts didn't start" when a map loads | The map's own Lua has an error; the line after it says which. maplua_unpack the scripts, fix them, pack them again. |
| "'greet' is already an engine command" | Pick a different command name, ideally prefixed with your mod's name. |
| The mod turned itself off | Ten errors in two seconds, usually an error inside update or draw. Fix the first error in the console, then save. |
| My variables reset when I save | That's hot reload. Keep state in orphan.persist. |
| The editor says NO GAME | Start the game (Ctrl+Shift+G). If it still won't attach, check live_link 1 in the game and live_status. |
Crash reports
A Lua error or an ASM trap is not a crash. It stays inside your mod and shows in the console and on the MODS screen. If the engine itself crashes, SPASTAZ writes a report to src/reports/crash_SPASTAZ_<date>_<time>.txt and tells the player where it is. The report contains:
- the exception (for example, a write to a NULL pointer) and where it happened,
- a stack trace with function names,
- which mod's code was running at the time,
- registers, the loaded mods, memory use, and the last 60 console lines.
Exception 0xC0000005 ACCESS_VIOLATION (bad pointer)
At spastaz.exe+0x6176A Cmd_CrashTest+0xC1
tried to WRITE address 0x0 (a NULL pointer, or NULL + small offset)
Mod engine code called from mod 'hover_pads' was running - check that mod first
If a player's report names your mod, the bug is usually something your mod asked the engine to do. Send the report along with the steps to reproduce. crash_test (with sv_cheats 1) crashes on purpose, to check the reporter works.
Console cheat sheet
modsList mods with state, CPU time and memorymod_info <mod>Details, requirements, content, settingsmod_orderLoad and event ordermod_enable <mod>Enable it and everything it requiresmod_disable <mod>Disable it; mods that need it stopmod_reload [mod|all]Reload, keeping orphan.persistmod_load / mod_unload <mod>For this session onlymod_new <name> [lua|asm]New mod from a templatemod_scanLook for new mod foldersmod_open <mod>Open the folder in Explorermod_emit <event> [args]Fire an event by handmenu_modsOpen the MODS screenlua <code> / lua_resetRun Lua in the console / fresh console stateasm_check / asm_run / asm_disasm <file>Check, run or disassemble OrphanASMasm_syscalls [filter]Every syscall with its registersmaplua_pack <map> <folder> [as]Put scripts into a map (chapter 17)maplua_unpack / maplua_stripGet a map's scripts out / take them outmod_maplua 0|1Run the scripts maps carrymod_trust / mod_sandbox <mod>Lift / put on the downloaded-mod rulesgive_swepsEvery scripted weapon (cheats on)mp_gamemode sandboxPlay the Sandboxlive_statusIs Orphan Editor attached?mod_hotreload 0|1Watch mod folders for changesmods_enabled 0|1Master switch for all modssv_cheats 1Allow cheat functions and crash_testOrphan Engine 0.1.0 · mod API 1. Every function's signature: docs/MOD_API.md in the source (generated from the game's own docs) and Orphan Editor's F1 panel.