Files
Cinderwild/docs/ADDON_SDK.md

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

  1. Easy to write. A modder writes a small Lua script + a manifest. No build step, no engine knowledge.
  2. Impossible to hurt the host. No filesystem, no network, no arbitrary code against the engine, no hanging the server tick, no unbounded memory.
  3. Server-authoritative. Addons run on the server. Their effects replicate to clients like any other game state.
  4. 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-luaAPI GDExtension) 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)

  1. Each addon gets its own fresh Lua VM (isolation between addons).
  2. 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 sandboxed print that routes to the server log).
  3. The host injects a single global, game, which is the entire API surface — the only way an addon touches the world.
  4. 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.
  5. Memory quota: the VM's allocator is capped; exceeding it aborts the addon, not the server.
  6. No reentry into the engine: the game API functions are thin, validated shims written in GDScript/C++. The addon can't reach the Godot API, the filesystem, or the network.
  7. 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.