Field Codex Β· Authoring

Write a weapon.
Write a world rule.

A Meme Wars plugin is a small piece of Lua that teaches the game something new β€” a weapon, an enemy, a hazard, an entire game mode β€” without a single line of engine code changing.

// the engine does the heavy lifting; your plugin is the glue that wires it together

1
table: engine
Lua 5.3
sandboxed VM
~5%
tick budget, maxed
0
core files to touch
01 Β· Orientation

What a plugin is

A plugin is a Lua source file. When the game loads it, the file runs once and registers things β€” a blueprint, a weapon, or a fire behaviour on the global engine table; a per-tick system or an event hook on your own plugin table. After that, the engine calls back into your registrations as the match plays.

Here is a complete, if pointless, plugin. It spawns a spark wherever anyone fires:

a whole pluginengine.on("fire", function(shooter, weapon)
  local x = engine.get(shooter, "pos", "x")
  local y = engine.get(shooter, "pos", "y")
  engine.spawn("spark-fire", x, y)
end)

There is no import, no main(), no boilerplate. The file is the plugin. Everything it can do, it does through engine β€” which is the only thing in scope besides Lua's own string, table, and math libraries.

The one big ideaThe engine owns the data and the hot loops; your plugin owns the decisions. You never hold a game object, loop over every entity, or touch a component in memory β€” you ask engine to do those things by handle. That single constraint is what keeps plugins fast, safe, and impossible to leak.

02 Β· Mental model

The three rules that make it work

Everything else follows from these. Internalize them and the API stops surprising you.

Rule 1 Β· Handles, not data

An entity crosses to Lua as a bare number β€” a handle. You never receive a component object. You read and write its fields through engine.get / engine.set, which reach into the world and come straight back out. Nothing to hold, nothing to leak.

Rule 2 Β· Scalars only

Only numbers, strings, and booleans cross the boundary in either direction. A function that would return a component, a table, or a reference returns nothing usable. The one exception is static config (a blueprint, a weapon spec), copied once at load.

Rule 3 Β· Hot loops stay in JS

You never write for every entity…. When you need per-entity work every tick, you declare a system: the engine runs the loop in JS and calls your Lua once per matching entity. Your code is glue; the iteration is the engine's.

These aren't style guidance β€” they're enforced by the boundary. A leaked reference simply can't form, and a hot Lua loop is something the API steers you away from writing.

03 Β· The engine API

Registering things

A few calls hook your code into the game: a fire behaviour by name on engine, and your per-tick work + event hooks on your own plugin table. The engine calls each back at the right moment.

engine.behavior(name, fn) β†’
A fire behaviour. When a weapon whose behavior is name fires, your fn(fire) runs. fire carries projectile, aim_x, aim_y, and the shooter handle. Registering an existing name replaces it β€” that's how a plugin overrides a built-in.
plugin.tick(fn, opts?) / plugin.system(filter, fn, opts?)
Your plugin's per-tick work. tick calls fn(dt) once a tick; system calls fn(id, dt) for every entity matching filter (a list of component names) β€” you never loop. A plugin implies one such system, named after it, that the engine slots into the pipeline for you. See Β§05.
plugin.on(event, fn) β†’
An event hook. The engine emits generic lifecycle events (fire, pickup, spawn, death); your fn(...) runs on each β€” but only while your plugin is enabled in the world. See Β§06.
engine.provide(name, table) / require(name)
Expose a Lua table of functions for other plugins to use, or fetch one another plugin exposed. Stays entirely inside the VM. See Β§08.
04 Β· The engine API

Reaching into the world

These act on the current world β€” the one whose tick is running. Ids are handles; every value in and out is a scalar.

engine.spawn(blueprint, x, y) β†’ id
Create an entity from a registered blueprint at (x, y). Returns its handle, so you can configure it further with set.
engine.get(id, comp, field) β†’ scalar
Read one scalar field of a component, or nil if the component is absent. Never returns an object.
engine.set(id, comp, {fields}) β†’
Write a table of fields onto a component: engine.set(id, "vel", { x = 0, y = -900 }). A no-op if the component is absent. Writes happen now; nothing is held.
engine.has(id, comp) β†’ bool
Whether the entity has that component.
engine.getIn / setIn(id, comp, field, key[, v])
Read/write one scalar inside a map-valued field β€” e.g. ammo.map[weapon]. The flat get/set can't reach nested tables; these can, while staying scalar-only.
engine.query(...comps) β†’ {ids}
A Lua array of every entity handle with all the named components. Use sparingly β€” prefer a declared system for per-tick work.
engine.emit({ t = "...", ... }) β†’
Emit an event. t names it; other scalar fields ride along. Damage, sound, and visual effects all flow through here β€” e.g. { t = "damage", target = id, amount = 20, owner = shooter }.
engine.destroy(id) β†’
Remove an entity from the world.
engine.rng() β†’ number
A deterministic random in [0,1), drawn from the sim's seeded stream. Always use this, never math.random β€” see Β§09.
engine.matchLive() β†’ bool
Whether the round is in live play (false during intermission). Gate anything that shouldn't run between rounds.
engine.data(key) β†’ config
Read a bag of static config the host was built with (e.g. weapon specs) β€” the JSON bridge. Deep-copied to Lua once, at load.
05 Β· The engine API

Declared systems

A system is how a plugin does work every tick, for every matching entity β€” without ever writing the loop.

You hand plugin.system a component filter and a function. The engine batches the query in JS and calls your function once per entity, as (id, dt). The hot iteration is the engine's; your Lua is a few glue calls. This is the whole reason a per-tick feature like the jetpack costs almost nothing. (For work that isn't per-entity β€” a world clock, a spawner β€” use plugin.tick(fn), called once a tick as fn(dt).)

a hazard that ticks damage to anyone standing in lavaplugin.system({ "pos", "health", "body" }, function(id, dt)
  local y = engine.get(id, "pos", "y")
  if y > 2000 then                              -- below the lava line
    engine.emit({ t = "damage", target = id, amount = 18 * dt, owner = 0 })
  end
end)
Read into locals, write onceThe clean shape reads every field you need up front, runs the logic on Lua locals, then writes back in one engine.set. That keeps the boundary a fixed handful of calls per entity instead of a chatty back-and-forth. The jetpack's whole state machine does exactly this β€” ~12 reads, 2 writes, per player, per tick.

Your plugin implies one system, named after it. The engine slots it into the world's pipeline for you β€” you don't list it anywhere. Where order matters, declare it in opts: { after = "control", before = "physics" } (either accepts a system name or a list; priority = -1 runs your plugin earlier than the normal bracket). With no anchors your system lands among its peers in an intentionally unstable order β€” so never rely on running before or after another plugin you didn't anchor to.

Work that doesn't need 30Hz can throttle: { every = 0.25 } fires the tick four times a second and hands your fn the accumulated dt, so a per-second rate (food/sec, damage/sec) comes out identical. Hunger drains this way.

06 Β· The engine API

Lifecycle events

The engine announces the moments that matter. Hook them with plugin.on to react β€” grant a buff on spawn, count a kill, spread fire on death.

on("fire", fn(shooter, weapon))
A shot was committed. shooter is a handle, weapon the weapon name.
on("pickup", fn(taker, item))
A player took an item. taker handle, item name.
on("spawn", fn(id))
A player entered play β€” first join or respawn.
on("death", fn(victim, killer))
An entity died. killer is a handle, or nil for a suicide / the void.

Every argument is a handle or a scalar, so a hook's arguments can be used with the world API directly. The engine emits these whether or not anyone listens, so hooking one is free until you do β€” and a hook only fires while your plugin is enabled in the world, so a hook-driven plugin switches off cleanly with its toggle.

07 Β· The engine API

Contributing content

Plugins own their data too. Declaring a blueprint or a weapon is the one place a whole structure crosses the boundary β€” and it's fine, because it's static config copied once at load, never live state.

engine.defineBlueprint(name, spec)
Register a spawnable entity template. spec.components is a table of component defaults. engine.spawn(name, …) then builds it.
engine.defineWeapon(name, spec)
Register a weapon: its label, behavior, cooldown, projectile, ammo, and so on. It joins the arsenal like any built-in.
the ember a fire-weapon lobsengine.defineBlueprint("fine-ember", {
  components = {
    pos = { x = 0, y = 0 }, vel = { x = 0, y = 0 },
    gravity = { g = 1400, grounded = false },
    lifetime = { ttl = 5 },
    ember = { owner = 0, team = "", fire = "fire-zone" },
  },
})
Booleans, and the Lua trapComponent fields can be booleans (grounded = false). Remember that in Lua 0 is truthy β€” if grounded then is only correct because grounded is a real boolean. For a numeric 0/1 flag, always test == 1, never rely on truthiness.

08 Β· Composition

Services & dependencies

Plugins build on plugins. An infrastructure plugin exposes an API; a content plugin depends on it and calls in. The dependency always points down β€” core never names a plugin, and a plugin never names its dependents.

engine.provide(name, table) publishes a table of functions. engine.require(name) fetches it. Both stay inside the VM, so a call like items.register("fine", …) is just a Lua call β€” no boundary crossing, no cost.

a content plugin standing on the items infrastructurelocal items = engine.require("items")
items.register("fine", { give = "weapon", weapon = "fine", ammo = 3 })

You declare which plugins yours needs; the host loads them in dependency order, so a provider always runs before anyone who requires it. A missing dependency or a cycle fails loudly at load, not mysteriously at runtime.

Reaching a service from the engineThe engine's own JS systems can call a plugin service too, through the same tables β€” that's how the pickup system hands items out through your items.give. You don't write that side; it's the seam that lets a plugin quietly take over a built-in job.

09 Β· Composition

Guardrails

Three things the host guarantees, so a plugin can't quietly break a match.

Determinism

The server sim must be reproducible, so randomness comes only from engine.rng() (the seeded stream). math.random is off-limits; a plugin that reached for it would desync clients from the server.

Error isolation

If your plugin throws at runtime, the host disables that plugin β€” its callbacks no-op from then on β€” and keeps the match alive. One bad plugin degrades to nothing; it never takes the tick down. Load-time errors, by contrast, are loud and immediate.

The sandbox

The VM sees engine plus string, table, math β€” and nothing else. No io, no os, no require, no load, no filesystem. A plugin can only reach what the engine hands it.

Your own scope

Every plugin runs in its own environment. Your top-level locals and your bare globals are private β€” another plugin can't see or clobber them, even by accident. You also get a unique plugin symbol: plugin.name is your plugin's name, at load and at runtime. To share across plugins, use provide/require β€” the one deliberate channel.

10 Β· Practice

A whole plugin, end to end

This is This Is Fine β€” a complete weapon, shipping in the game, in one file. It declares the ember it lobs, the weapon that lobs it, the fire behaviour, and its pickup item. It depends only on items. Core names none of it.

content/plugins/this-is-fine.lua β€” abridged-- the ember it lobs (a gravity-bound orb that lights a fire where it lands)
engine.defineBlueprint("fine-ember", {
  components = {
    pos = { x = 0, y = 0 }, vel = { x = 0, y = 0 },
    gravity = { g = 1400 }, lifetime = { ttl = 5 },
    ember = { owner = 0, team = "", fire = "fire-zone" },
  },
})

-- the weapon: lob a fine-ember on a long cooldown
engine.defineWeapon("fine", {
  label = "This Is Fine", behavior = "drop", cooldown = 4.5,
  projectile = "fine-ember", ammo = { max = 6, start = 0 },
})

-- the fire behaviour: spawn the ember at the aim, stamped with the shooter's team + id
engine.behavior("drop", function(fire)
  local id = engine.spawn(fire.projectile, fire.aim_x, fire.aim_y)
  engine.set(id, "ember", {
    team  = engine.get(fire.shooter, "body", "team"),
    owner = fire.shooter,
  })
end)

-- its pickup item, registered into the items infrastructure
local items = engine.require("items")
items.register("fine", { give = "weapon", weapon = "fine", ammo = 3 })

Every mechanism in this guide appears here: a blueprint, a weapon, a fire behaviour that spawns-and-stamps by handle, and a dependency on an infrastructure plugin. Nothing else in the codebase knows this weapon exists.

11 Β· Practice

Is it a primitive, infrastructure, or content?

When you're not sure where something belongs, the deciding question isn't importance β€” it's frequency and shared-ness.

  • Content-agnostic and needed by everything? β†’ a primitive in the JS engine. The ECS, spawn, query, events, terrain, rng. You use these; you don't write them.
  • A game system others build on, that a world might toggle? β†’ an infrastructure plugin (Lua). Items, lighting, day/night, hunger. It exposes an API via provide.
  • An individual piece of content? β†’ a content plugin (Lua). A weapon, an enemy, a hazard. It depends on infrastructure and names itself to no one.

The litmus for the hard cases: would a sandbox reasonably run without it? A world with no fire weapons is sensible β€” so fire is a plugin. A world with no ability to spawn entities at all is not β€” so spawn is a primitive.

The boundary, in one lineHot, shared, per-tick work is a JS primitive. Cold orchestration that wires primitives together a handful of times per event is a plugin. Frequency and shared-ness decide β€” not how central the feature feels.


Meme Wars plugin system Β· runs on a sandboxed Fengari (Lua 5.3) host, server-side, one per world. Companion documents: docs/plugin-architecture.md (the boundary & migration) and docs/plugin-viability.md (how far plugins can go).