Mods

Modding guide · Orphan Engine · Mod API 1

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.

engine 0.1.0mod API 1Lua 5.4mods folder src/mods/
01

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:

ModKindShows you
hello_luaLuaConsole commands, events, a task, saving stats between sessions
asm_tutorialASMA guided tour: loops, recursion, strings, floats, macros, drawing
sandboxLua modeThe 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).
directorLuaF7 opens a film director: recorded views, dolly shots from keyframes, orbit and follow cameras, a letterbox (chapter 15)
gang_warsLua modeTwo gangs with their own colours and names; SWAT pull up 1:30 in, for both of them
hostage_rescueLua modeCivilians, an objective line, and markers that only one side sees
last_standLua modeOne side against waves of bots, with supplies dropped on the ground between waves
zombiesLua modeWaves of the dead: walkers, runners, crawlers, leapers and brutes, with bots driven from Lua
surfLua modeSource-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.

02

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.

CommandBuilds
build.bat allThe engine library lib/liborphan.a, then bin/spastaz.exe and bin/orphan_editor.exe
build.bat gameEngine library, then the game
build.bat editorEngine library, then Orphan Editor
build.bat engineJust lib/liborphan.a
build.bat run-game / run-editorStarts 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 (press F1), 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

KeyDoes
F5Run the current file in the preview with the debugger (or continue when paused)
Ctrl+F5Run without the debugger
F9Toggle a breakpoint (or click left of the line numbers)
F10 / F11 / Shift+F11Step over / into / out
F1API reference panel
Ctrl+SpaceAutocomplete (it also pops up while you type)
Ctrl+P / Ctrl+Shift+POpen a file by name / run any editor command
Ctrl+Shift+GLaunch the game (the editor attaches to it on its own)
03

Your first mod

This walks through a small Lua mod, from an empty folder to a feature with a setting, in about ten minutes.

  1. Create it.In Orphan Editor pick Mods → New Lua Mod and call it first_mod. You can also type mod_new first_mod in the game console, or click NEW MOD on the MODS screen. You get mods/first_mod/mod.cfg and a working main.lua.
  2. Say hello.Replace main.lua with:
    orphan.on("load", function()
      orphan.ok("first_mod is alive")
    end)
    
    orphan.command("greet", "Say hi", function(name)
      orphan.log("hi " .. (name or "operator"))
    end)
    Press Ctrl+Shift+G to start the game, open the console with ` and type greet Sam.
  3. 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)
  4. Keep state across reloads.Plain locals reset every time you save. Anything you put in orphan.persist survives:
    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)
  5. Add a setting.orphan.newcvar makes 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:
    local 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)
    Use show and size in your draw handler, then flip the switch on the MODS screen.
  6. 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 F10 to step, F5 to continue.
  7. Fill in the manifest.Open mod.cfg and give your mod a real name, version and description. That's what players see on the MODS screen (chapter 4).
04

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)
KeyDefaultMeaning
namefolder nameDisplay 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
entrymain.luaThe file that runs. .oasm, .asm or .s means OrphanASM. none: no code (files other mods use).
enabled0The MODS screen and mod_enable/mod_disable rewrite this line for you
priority0Lower numbers load and get events first
api_versionassumed 1See 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 (a requires b, b requires a) 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 saysWhat happens
nothingLoads, with a warning to add api_version
lower than the engineLoads, with a warning to check what changed (chapter 24)
the sameLoads normally
higher than the engineRefuses 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.

05

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).

EventArgumentsWhen
load(none)After your main file ran
unload(none)Before the mod unloads or hot reloads
updatedtEvery 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)
keynameA key was pressed while the console is closed ("F10", "A"...)
cvarname, valueOne of your settings changed
round_startnA round of a match started (chapter 6)
round_endwinner, whyA round ended: the winning team (-1 a draw) and the reason shown
deathvictim, killerSomeone in the match died (actor ids; killer -1 when nobody did it)
spawnid, teamSomeone (re)spawned in the match
anything elsewhatever was sentA 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

FunctionDoes
orphan.log(...), ok, warn, errorPrint 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, gamenameYour 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)
FunctionDoes
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).

FunctionDraws
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]), circleLinesFilled / 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)
FunctionDoes
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.

FunctionReturns
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.

FunctionDoes
orphan.save(name, table) / orphan.load(name)Keep a table between game sessions, in <mod>/<name>.save
orphan.persistA 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), appendPlain file access
orphan.fs.exists, list([folder]), remove, mkdirFolder 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.

06

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

FunctionReturns
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)

GroupFunctionDoes
Playersstrip(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
Botsaddbot(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 roundbanner(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 worlddrop(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.

FunctionSlotsDoes
set(slot, number) / get(slot)1-16Shared numbers: scores, timers, a wave counter
text(slot, line) / text(slot)1-4Shared 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-8A 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.

FunctionDoes
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.

FunctionDoes
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.

EventReturn
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.

FunctionDoes
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
}
FieldDefaultMeaning
id(required)What mp_gamemode takes
name, descthe id, (none)Shown in PLAY > GAME MODE
teamsSWAT / SUSPECTSThe two sides' names
colorsthe usualEach side's shirt and HUD colour; false keeps a side's usual look
botNameslobby namesWhat each side's bots are called
streetClothesfalseNobody in the SWAT uniform (team 3 still is); the bots talk like it
oneSidefalseEveryone's on side 0; the mode brings the enemy (addbot on team 3)
teamMax{0, 0}Most players on each side (0: no limit)
huntersfalseBots go looking for the other side instead of holding
damage1Bullets hit this much harder
roundTime, roundsmp_roundtime, 0Seconds a round; rounds a match (0: the server's setting)
respawn, respawnDelayfalse, 5Players come back after this many seconds
map(any)Only ever played on this map
economy, shop, claimBoard, civilians, objectives, planning, debrieffalseSwitch on the game's own systems: money, the shop, the role board, civilians, mission objectives, the planning phase, the debrief
HookRuns onWhen
matchStart()hostA match on your mode starts
roundStart()hostEach round, before anyone spawns
spawn(id)hostSomeone spawns: give them their kit
tick(dt)hostEvery tick of a live round
checkEnd()hostReturn 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)hostSomeone died
banner(team)every machineReturn the line shown to that side under GO GO GO
hud(w, h)every machineDraw 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.

07

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.

EventArgumentsReturn
damagevictim, attacker, amount, weapon, headshotfalse stops the hit; a number is the damage it does instead
chatid, text, teamOnlyfalse swallows the line; a string replaces it
fire_bulletshooter, weaponId, weaponfalse: no round leaves the gun
can_pickupactor, item, name, kindfalse: they can't take it
can_arrestvictim, byfalse: the cuffs don't go on
can_respawnactor, deadForfalse: they wait (asked again later)
can_useent, actorfalse: 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

EventArgumentsWhen
round_start, round_endn / winner, whyA round started / ended (winner -1: a draw)
spawn, deathid, team / victim, killerSomeone (re)spawned / died (killer -1: nobody)
player_join, player_leaveactor, nameA person (not a bot) came in / went
woundedactor, woundsA new wound (how many they have now)
bone_breakactor, boneskull, jaw, ribs, pelvis, left arm, right arm, left leg, right leg
limb_lost, dislocatedactor, limbShot or blown off / out of its socket
knocked_out, woke_upactorOut cold / came round
went_down, got_upactorOn the floor as a ragdoll / up again
heart_stopped, heart_startedactorCardiac arrest / back
stopped_breathing, breathingactorThe lungs stopped / started
seizure, arrestedactorA fit started / cuffed
drug_doseactor, drug, dosesThey took something (chapter 12)
treatby, patient, itemA medical item used on someone
item_pickupactor, nameThey took something
weapon_switchactor, nameWhat's in their hands changed
weapon_deployactor, nameA scripted weapon came out
vehicle_enter, vehicle_exitactor, vehicleGot in / out of a car
noisex, y, z, radius, kind, sourceA shot, a bang or a voice the bots can hear
explosionx, y, z, power, ownerSomething went off
glass_breakpane, x, y, z, gone, byA window hit (gone: it fell out)
door_breachdoor, blownA door forced (blown: by a charge)
wall_blast, wall_hitx, y, z, power / brush, x, y, z, howThe building breaking
trigger_enter, trigger_leaveid, eventSomeone stepped into / out of one of the map's trigger parts
entity_created, entity_removedid, classAn entity was spawned / is gone
entity_damageid, amount, attacker, kindAn entity took a hit
case_readycaseNoThe 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)
08

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.

HookRunsGets
initHost, when it's spawnedself
thinkHost, every tickself, dt
useHost, someone pressed E on itself, actor
touchHost, it hit or was hit by somethingself, other, kind (kind: world, ent, prop, ragdoll, item, actor)
damageHost, a hitself, amount, attacker, kind, x, y, z; return false to ignore it
removeHost, it's goingself
clientInit, clientThinkEvery machine, when it first sees it / every frameself, self, dt
drawEvery machine, the 3D passself (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).

MethodDoes
pos, setPos, angles, setAngles, rot, setRotWhere it is and how it's turned (degrees or a quaternion)
forward, right, up, toWorld, toLocalIts own axes, and points between its frame and the world's
vel, setVel, angvel, setAngVelHow it's moving
push, force, torqueAn impulse, a force for this step, a turning force (optionally at a point)
mass, shape, modelRead or change them
freeze, nocollide, nodraw, glow, nogravity, trigger, kinematicSwitches (a trigger passes through things and only calls touch)
health, owner, lightWhat 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, validThe 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.

FunctionDoes
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, massWhere 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.

09

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)
10

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)
FunctionDraws
line, beam, cylinder, capsuleLines; 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.

11

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

FunctionDoes
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
12

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)
FunctionDoes
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
13

Doors, power and weather

FunctionDoes
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.

14

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.

FunctionTrue 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.

15

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.

16

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.

KeyDoes
QThe spawn menu: props, entities, weapons, the tool gun's tools and their settings
5Your physics gun and tool gun
Physics gunLMB grab, RMB freeze, R unfreeze (hold R and move the mouse to turn it), wheel nearer / further
Tool gunLMB / 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, NUndo the last thing you made, noclip
NumpadThrusters, 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.

17

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.

  1. Write them like any mod, in a folder (say mods/my_map_scripts/) with a main.lua. Use orphan.game.map(), the map's markers (orphan.game.spawns("my_marker")) and its trigger events.
  2. In the console: maplua_pack my_map mods/my_map_scripts. That puts them into assets/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.
  3. 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.

18

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; r14 is also fp, r15 is sp) and f0–f7 (32-bit floats).
  • Flags: Z (zero/equal), N (negative/less), C (unsigned less), set by cmp, test and fcmp.
  • Memory: 64 KB by default, up to 4 MB with .memory. Addresses below 16 trap (a null-pointer guard), your .data starts 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). Use yield to spread long work over several frames.

Entry points

LabelRunsGets
init:Once, when the mod loads(nothing)
update:Every framef0 = 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

DirectiveExampleDoes
.text / .dataSwitch between the code and data sections
.word.word 1, label, -332-bit integers
.byte.byte 1, 'a', "text"Bytes; strings expand to characters
.float.float 1.5, 0.2532-bit floats
.string.string "hi\n"Zero-terminated string (also .asciz). .ascii leaves off the zero.
.space.space 64Zeroed bytes (also .zero)
.align.align 4Pad data to a multiple
.equ.equ MAX, 42 or MAX = 42Named constant
.memory.memory 131072Total VM memory in bytes (max 4 MB)
.include.include "lib.oasm"Paste another file from your mod folder
.macro / .endm.macro PRINT_INT vMacros. \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.

GroupInstructions
Movemov lea xchg
Integer mathsadd sub mul div mod divu modu min max inc dec neg abs. Dividing by zero traps.
Bitsand or xor not shl shr sar
Comparecmp test fcmp
Memoryld st (32-bit), ldb stb (byte), fld fst (float), push pop pushf popf
Jumpsjmp jz/je jnz/jne jl jle jg jge (signed) jb jbe ja jae (unsigned) loop
Callscall ret sys
Float mathsfmov fadd fsub fmul fdiv fmin fmax fsqrt fabs fneg fsin fcos ffloor itof ftoi
Controlnop 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.

GroupSyscalls
Outputprint print_hex print_f print_str print_char newline
Memory and textmemcpy memset strlen strcmp itoa ftoa atoi
Mathsrand randf seed atan2 pow lerp clampf
Engineexec cvar_get cvar_set time dt frame fps screen_w screen_h cheats emit
Inputkey (name → code) key_down key_pressed mouse mouse_down
Drawingdraw_rect draw_rect_lines draw_line draw_circle draw_text draw_int text_width. Colours are 0xRRGGBBAA in r1.
Textures and soundtex_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/.

19

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 cvar event. Update your local copy there.
  • Up to 32 settings and 32 commands per mod.
20

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: F10 over, F11 into, Shift+F11 out, F5 continue, F6 pause 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.cfg mistakes (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/F11 step, F5 continues.
  • Pause on demand: F6 with 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

CommandDoes
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
21

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.

LimitDefaultWhat happens
Time per Lua call (mod_lua_budget)100 msThe call stops with "script ran for over 100 ms". Use orphan.wait to split long work.
Lua memory per mod (mod_lua_mem)64 MBAllocations fail with "not enough memory"
Instructions per ASM call (mod_asm_budget)2,000,000The program traps. Use yield.
Errors in a row10 in 2 sThe mod is switched off until it's reloaded (save a file or mod_reload)
Tasks per mod128Extra tasks are dropped with an error
Settings / commands per mod32 / 32Error when you make one more
Textures per mod64 (256 MB)gfx.load returns nil and a reason
Sounds per mod64sound.load returns nil and a reason
ASM memory64 KB (max 4 MB)Set with .memory; out-of-range access traps
Installed mods64Further folders are ignored
Entities / ropes and cables in the world256 / 128ents.spawn returns nil and a reason
Scripted weapons64weapons.define errors
Net messages from one client120 a secondThe host drops the rest. 8 arguments, strings up to 1024 characters.
Map scripts256 files, 4 MBThe 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 in mods/.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 ASM exec syscall);
  • 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.

22

Troubleshooting

You seeWhy, 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 falseIt'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 mouseA 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 loadsThe 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 offTen 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 saveThat's hot reload. Keep state in orphan.persist.
The editor says NO GAMEStart the game (Ctrl+Shift+G). If it still won't attach, check live_link 1 in the game and live_status.
23

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.

24

Sharing your mod

  1. Fill in name, version, author, description and api_version.
  2. Set enabled = 0 so players choose to turn it on.
  3. List every mod you rely on in requires, with a minimum version if you use something new in it.
  4. Delete your own *.save files, and any test files.
  5. Zip the folder (not just its contents).

Upload the zip to the workshop on the board that fits (Mods, Weapons & gear, Sandbox addons, Maps). Players install it from MODS > WORKSHOP in the game. By hand, they unzip it into src/mods/, press RESCAN on the MODS screen (or restart), and switch it on.

Servers running it

A server lists its game mode and the mods joining players need, and the server browser (on the site and in the game) shows them and can filter on them: type sandbox and you see the Sandbox servers. Players who haven't got a mod download it from the server as they join.

Versioning

Bump version whenever you change something other mods might depend on: an event you emit, a setting, a weapon name. Mods that need the new behaviour can then say requires = your_mod >= 1.3.

API changes

APIEngineWhat's in it
1updateThe world and everything in it:
  • orphan.ents and orphan.phys: scripted entities, traces, constraints (chapter 8). orphan.weapons: scripted tools and guns (chapter 9).
  • orphan.draw3d, orphan.fx, orphan.ui, orphan.camera, orphan.body, orphan.forensics, orphan.world.
  • Vetoes and many new events (chapter 7); orphan.ask.
  • Realms: orphan.server / client / dedicated, orphan.include, net.receive, private in mod.cfg, a message limit (chapter 14).
  • Maps carry their own Lua (chapter 17); orphan.game.map().
  • The Sandbox and Director mods. The menu sim, orphan.sim, its ASM syscalls and content mods are gone with it.
1updateMatches and game modes:
  • Lua: orphan.game, the match API, and game modes from mods (chapter 6). The game's name string moved from orphan.game to orphan.gamename.
  • orphan.game: drop, set/get, text, objective, marker, screen. New actor() fields: arrested, hostage, extracted, surrendered, down.
  • Modes: botNames, oneSide, teamMax. A side's colors are used on the HUD too.
  • Events: round_start, round_end, death, spawn.
  • mod.cfg: network. Servers check and send mods; downloaded mods are sandboxed (mod_trust, mod_sandbox).
10.1.0The first version:
  • Lua: orphan.*, gfx, sound, music, sim.content/next/tune/weapon, and newcvar with min/max and switches.
  • ASM: the texture, sound, music and sim_next/sim_tune syscalls.
  • Content: maps, buildings, weapons, tuning.
  • mod.cfg: requires and api_version.
25

Console cheat sheet

modsList mods with state, CPU time and memory
mod_info <mod>Details, requirements, content, settings
mod_orderLoad and event order
mod_enable <mod>Enable it and everything it requires
mod_disable <mod>Disable it; mods that need it stop
mod_reload [mod|all]Reload, keeping orphan.persist
mod_load / mod_unload <mod>For this session only
mod_new <name> [lua|asm]New mod from a template
mod_scanLook for new mod folders
mod_open <mod>Open the folder in Explorer
mod_emit <event> [args]Fire an event by hand
menu_modsOpen the MODS screen
lua <code> / lua_resetRun Lua in the console / fresh console state
asm_check / asm_run / asm_disasm <file>Check, run or disassemble OrphanASM
asm_syscalls [filter]Every syscall with its registers
maplua_pack <map> <folder> [as]Put scripts into a map (chapter 17)
maplua_unpack / maplua_stripGet a map's scripts out / take them out
mod_maplua 0|1Run the scripts maps carry
mod_trust / mod_sandbox <mod>Lift / put on the downloaded-mod rules
give_swepsEvery scripted weapon (cheats on)
mp_gamemode sandboxPlay the Sandbox
live_statusIs Orphan Editor attached?
mod_hotreload 0|1Watch mod folders for changes
mods_enabled 0|1Master switch for all mods
sv_cheats 1Allow cheat functions and crash_test

Orphan 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.