Custom commands
Describe your own commands in a JSON file, input fields included, without handing anyone the raw console.
The built-in catalogue covers moderation: kick, ban, mute, broadcast. Anything beyond that — the Lua incantations every server ends up keeping in a text file — is described in a JSON catalogue you provide.
The point is not only convenience. A command declared here is
bounded: a moderator gets a “Kill all enemies” button
with a player field, without ever receiving rcon:raw — that
is, without being able to run arbitrary Lua.
Where the file lives#
The path comes from CUSTOM_COMMANDS_FILE, by default
/factorio-config/commands.json — already mounted read-only by
the docker-compose.yml. Dropping the file into
./data/config/ is therefore enough.
A complete, ready-to-use example lives in
examples/commands.json.
/api/ready reports commands: false.
A complete example#
{
"version": 1,
"groups": {
"cleanup": { "en": "Cleanup", "fr": "Nettoyage" }
},
"commands": [
{
"id": "kill-enemies",
"group": "cleanup",
"permission": "action:moderate",
"label": { "en": "Kill all enemies", "fr": "Tuer tous les ennemis" },
"hint": { "en": "Every enemy entity on the player's surface" },
"confirmation": { "en": "Destroy every enemy entity?" },
"params": [
{
"name": "player",
"type": "player",
"label": { "en": "Player" },
"help": { "en": "Only used to pick the surface" }
}
],
"template": "/c local s = game.players[{{player}}].surface local n = 0 for _, e in pairs(s.find_entities_filtered({force = \"enemy\"})) do e.destroy() n = n + 1 end rcon.print(n .. \" entities destroyed\")"
}
]
}A command's keys#
| Key | Default | Meaning |
|---|---|---|
id | required | Unique. Exposed and audited as custom:<id>, so it can never collide with a built-in action. |
label | required | Button text. A plain string, or an object per language: { "en": …, "fr": … }. |
template | required | The command, with its {{name}} markers. |
group | custom | Heading in the quick actions. Its label comes from the groups table. |
permission | action:custom | action:info, action:moderate, action:server or action:custom. See below. |
risk | dangerous | dangerous colours the button as destructive. |
confirm | true | Ask for confirmation before running. |
preview | true | Show the rendered command in that confirmation. |
hint | — | Help line under the button. |
confirmation | — | Text of the confirmation dialog. |
params | [] | The fields the user fills in. |
permission default is deliberately strict
An entry that says nothing stays administrator-only. Opening a command to
moderators is an explicit gesture, not an oversight. rcon:raw is
not offerable: a catalogue action is bounded by definition, and the raw console
already exists for the rest.
Field types#
type | Accepts | Inserted as |
|---|---|---|
player | No space, quote or backslash; 60 characters | Lua string |
text | No line break; maxLength (200 by default) | Lua string |
identifier | A prototype name: iron-plate, steel-processing | Lua string |
int / float | A number within min…max | Bare number |
bool | A checkbox | true / false |
enum | One value out of options | Lua string, or bare with "raw": true |
Every field also takes required (true by default),
default, label, placeholder and
help.
default
Otherwise an empty field has no meaning: the empty string is not a number. The
loader rejects the entry rather than failing at execution time.
Writing a template#
The template never writes its own quotes#
You write game.players[{{player}}], not
game.players["{{player}}"]. The panel builds the complete
Lua literal, quotes included, and escapes whatever the user typed.
This is what makes the delegation safe: a value cannot break out of the string it lands in. A template that wrote its own quotes would bring the problem back, since imperfect escaping would then be enough to escape.
Two insertion modes#
| Marker | Produces | For |
|---|---|---|
{{name}} | A complete Lua literal: "Edwins", 250, true | Lua commands (/c, /silent-command) |
{{arg:name}} | The bare, already-validated value: Edwins | Regular commands: /kick {{arg:player}} {{arg:reason}} |
-- is refused inside a template
Line breaks are flattened into spaces before sending — which is what lets
you write a template across several lines — but a Lua comment would then
swallow the rest of the command. Negative numbers are parenthesised for the
same reason: x-{{n}} would otherwise produce x--5.
Two Factorio pitfalls#
game.player does not exist over RCON#
Nearly every example on the wiki
is written for a game client's console and uses game.player.
Over RCON there is no current player: game.player is
nil and the command fails.
| Wiki (game client) | Server / RCON |
|---|---|
game.player | game.players["Name"] or game.get_player("Name") |
game.player.surface | game.players["Name"].surface or game.surfaces["nauvis"] |
game.player.force | game.forces["player"] |
game.player.print(x) | rcon.print(x) — otherwise the output never reaches the panel |
This is why the player field type is the central case rather than
an edge case, and why the shipped example catalogue is rewritten for a server
instead of copied from the wiki.
/c disables achievements#
Permanently, for the save in question. They are disabled in multiplayer
anyway, but it is worth knowing before handing out /c buttons.
What the panel refuses#
The file is trusted: it is written by whoever deploys the container, on the same footing as an environment variable. What is not trusted is the values typed into the interface. The boundary between the two is where everything is decided.
- Per-type allow-list — a
playerrefuses quotes, backslashes, spaces and control characters. - Literal built, never concatenated — a single function produces Lua literals, so there is only one place to audit.
- Unknown marker refused at load time — every
{{…}}must match a declared parameter. - Template size bounded — 4,000 bytes, the limit of an RCON frame.
- Unique ids, prefixed
custom:.
Concretely, a moderator typing
x"] rcon.print("pwned") [ into a player
field gets a 400 and nothing reaches RCON. In a text
field, where quotes are legitimate, the value is escaped and the literal stays
closed.
Checking your catalogue#
At startup the panel logs what it loaded:
{"level":"info","msg":"commands: catalogue loaded","file":"/factorio-config/commands.json","loaded":11,"rejected":0}A rejected entry appears at warn level with its id and the
reason. And /api/ready tells the two cases apart: file absent
(feature inactive, ok) versus file present but unreadable
(commands: false).