5.9 KiB
5.9 KiB
Addon SDK — design spec
This is the differentiating feature: easy add-ons with guard rails. Modders get a friendly, well-documented API; the server stays safe from buggy or malicious addons.
Design goals
- Easy to write. A modder writes a small Lua script + a manifest. No build step, no engine knowledge.
- Impossible to hurt the host. No filesystem, no network, no arbitrary code against the engine, no hanging the server tick, no unbounded memory.
- Server-authoritative. Addons run on the server. Their effects replicate to clients like any other game state.
- Content without code where possible. Many mods (new items, recipes, building pieces) are pure data — declared in a manifest, no Lua needed.
Why Lua (not WASM, not GDScript, not Python)
- Lua is the industry-standard embedded game-modding language. Modders already know it (WoW, Factorio-ish, Garry's Mod, countless others).
- A Lua VM is trivially sandboxable: you build the environment table yourself, so the addon literally cannot see anything you don't hand it.
- GDScript was rejected: it can reach engine internals — too powerful for untrusted code.
- WASM was considered (stronger isolation, authors use Rust/AssemblyScript) but is harder for casual modders. Keep it as a possible advanced lane later.
- Embedding: a Lua-in-Godot binding (e.g.
godot-luaAPIGDExtension) or a custom GDExtension wrapping Lua 5.4 / LuaJIT.
Addon package layout
my_addon/
addon.json manifest (required)
main.lua entry script (optional — data-only addons skip it)
items/ data definitions (optional)
prefabs/ low-poly meshes/scenes the addon adds (optional, validated)
assets/ textures, icons (optional, size-capped)
addon.json (manifest)
{
"id": "com.paul.ironpike",
"name": "Iron Pike",
"version": "1.0.0",
"api": "1", // SDK API version it targets
"author": "Paul",
"permissions": ["items", "recipes", "events"], // explicit capability opt-in
"entry": "main.lua" // omit for data-only addons
}
- Permissions are explicit. An addon only gets the API groups it declares, and the server can refuse/limit them. A data-only addon declares no code perms at all.
The sandbox (how the guard rails actually work)
- Each addon gets its own fresh Lua VM (isolation between addons).
- The global environment is a whitelist table we build — standard-library
access is stripped: no
os,io,package,require,dofile,loadfile,load,debug. Only safe primitives (math,string,table,pairs,ipairs,tostring, a sandboxedprintthat routes to the server log). - The host injects a single global,
game, which is the entire API surface — the only way an addon touches the world. - Instruction/time quota: every addon callback runs under a hook that counts VM instructions (or wall-clock) and aborts if it exceeds the budget. An addon that loops forever is killed, the tick is never blocked.
- Memory quota: the VM's allocator is capped; exceeding it aborts the addon, not the server.
- No reentry into the engine: the
gameAPI functions are thin, validated shims written in GDScript/C++. The addon can't reach the Godot API, the filesystem, or the network. - Fault isolation: an error in an addon callback is caught, logged with the addon id, and the addon can be disabled — the server keeps running.
API surface (v1 sketch — game.*)
Events (register handlers):
game.on("player_join", function(player) ... end)
game.on("block_place", function(player, piece, pos) ... end) -- can veto
game.on("tick", function(dt) ... end) -- quota-capped
game.on("entity_death", function(entity, killer) ... end)
World queries (read-only, validated):
local p = game.players.get(id) -- safe player handle
local n = game.world.count_entities("tree")
local b = game.world.get_block(x, y, z) -- within loaded bounds only
World commands (intents the host validates & applies):
game.world.spawn("wolf", x, y, z) -- subject to limits/quotas
game.chat.broadcast("Welcome, " .. p.name)
game.inventory.give(p, "wood", 5) -- bounded
Content declaration (data, validated at load):
game.define_item{ id="iron_pike", name="Iron Pike", damage=42, stack=1 }
game.define_recipe{ output="iron_pike", inputs={iron=10, wood=4}, station="forge" }
Everything an addon can do lives under game.. If it's not there, the addon can't
do it — that's the guard rail.
Limits the server enforces (configurable by the host owner)
- Max addons loaded
- Per-callback instruction/time budget
- Per-addon memory ceiling
- Rate limits on spawn/give/broadcast (so an addon can't spam)
- Asset size caps (prefab poly count, texture dimensions)
- Permission allow/deny per addon
Threat model (what we explicitly defend against)
| Threat | Defence |
|---|---|
| Addon reads server files / secrets | No io/os; no filesystem in the sandbox env |
| Addon opens network connections | No socket library exposed |
| Addon hangs the server (infinite loop) | Instruction/time quota aborts the callback |
| Addon exhausts RAM | Per-VM memory cap |
| Addon crashes the server | Every callback wrapped; errors isolate & disable the addon |
| Addon escalates via another addon | Each addon = separate VM, no shared globals |
| Addon spams spawns/messages | Host rate limits on world commands |
| Malicious prefab (huge mesh) | Asset validation + poly/texture caps at load |
A test we must pass (M3 done-criteria)
Ship a deliberately-malicious example addon in the test suite that tries to: read a file, open a socket, loop forever, and allocate gigabytes. The server must contain all four, log them, disable the addon, and keep running. If that passes, the guard rails are real.