Authoring
Add your own blocks and levels
A mod adds blocks and items in Lua. It cannot change the rules of the game, and it does not need to: the snake's whole behaviour is built out of blocks, so a mod that adds blocks adds things players can now build.
Mods use the same two registration calls as the base game, run in the same sandbox, and
get the same budget. If you can read lua/blocks/core.lua, you can already
write the equivalent block in a mod. Levels are a separate, simpler thing — a plain text
file with the map drawn in it, dropped in levels/custom/ — and they are
covered in part two.
Part one
Blocks
Make the folder
init.lua is the only file the game loads. That is the whole
installation process — drop the folder in mods/ and start the game.
mods/
my_mod/
init.lua
Load order is alphabetical by folder name, and mods always
load after the built-in content. Both lua/ and
mods/ hot-reload while the game runs, so you should not need to restart
while writing one.
Write a block
Put this in init.lua and run the game.
register_block {
id = "my_mod_turn_at_walls",
name = "Wall Panic",
category = "sensing",
module = "sensor", -- it touches the world, so it needs the hardware
cost = 1,
exec = { "blocked", "clear" },
execute = function(ctx)
return ctx.sensor.touch().forward == "wall" and "blocked" or "clear"
end,
}
The fields
| Field | Type | Meaning |
|---|---|---|
| id | string | Unique across the whole game. Prefix it with your mod name. |
| name | string | Shown in the editor. Keep it short — the block header is narrow. |
| category | string | action, sensing, condition,
flow, memory, source,
event. Decides the header colour and where it sits in the list. |
| cost | int | Ops. Charged against the player's 10-per-tick budget. Most blocks are 1; real sensing is worth 2. |
| module | string? | The body module this needs: sensor, memory or
processor. Declare sensor on anything calling
look, smell or touch. |
| params | list | Values edited on the block itself. Each is
{ id, type, default, min, max, options, desc }. |
| inputs | list | Typed data inputs pulled from other blocks' outputs.
{ id, type, default, desc }. |
| outputs | list | Typed data outputs other blocks can read. { id, type, desc }. |
| exec | list | Branch labels — { "yes", "no" }, or
{ { "yes", desc = "…" } } to document them.
Its presence decides the block's shape. |
| exec_in | bool | Set false to refuse an incoming exec wire. Only
Start does this. |
| desc | string | The help text. This is what the wiki and the in-game info panel show. |
| execute | function | function(ctx, inp, p) returning
branch_label, outputs_table. |
Types you can use for params, inputs and outputs:
int, float, bool, string,
direction, cell_kind, any. Data wiring is
type-checked at load — a direction output only connects to a
direction or any input.
The three shapes
Shape is derived from the definition, not declared, and it is the easiest thing to get wrong.
| Shape | exec | Behaviour |
|---|---|---|
| Branching | present, non-empty | Exec in, one labelled exec out per entry. |
| Terminal | present, empty {} |
Exec in, nothing after. Ends the program — only Stop does this. |
| Source | absent | No exec ports at all. Never in the control flow; runs only when a data wire reads it. |
exec = {}. A source node must also declare at
least one output, or nothing can ever reach it — the loader rejects that.
Writing an action block
An action is any block that calls something on ctx.act.
Doing so resolves the tick's single action and suspends the thread — so an action block
still declares an exec output, because execution resumes there on the following tick.
-- A step that only moves when the way is clear, and burns the tick
-- standing still when it is not. Two ops: it senses and it acts.
register_block {
id = "my_mod_step_safely",
name = "Step If Clear",
category = "action",
module = "sensor",
cost = 2,
desc = "Moves one cell forward when nothing is in the way, and waits " ..
"the tick out when something is. Either way the tick ends here.",
exec = {
{ "moved", desc = "the way was clear and the snake advanced" },
{ "blocked", desc = "something was in the way, so it waited instead" },
},
execute = function(ctx)
local ahead = ctx.sensor.touch().forward
if ahead == "wall" or ahead == "body" or ahead == "door" then
ctx.act.wait()
return "blocked"
end
ctx.act.move()
return "moved"
end,
}
execute is not twice as fast — the first one
ends the tick. Return the branch the thread should resume from, and let the next tick
do the next thing.
Exec wires push, data wires pull
Exec wires carry control flow. Evaluation follows them from the entry
node, and your execute returns a branch label saying which one to take.
They may contain cycles — that is how a loop is written, and the ops budget makes it
safe.
Data wires carry values. Reading an input executes the source node on demand, charges its ops, and caches it for the rest of the tick. So a source node nobody reads costs nothing, and a value read twice in one tick is computed once. Data wires must be acyclic.
Items
register_item { id = "my_mod_plum", name = "Plum", weight = 5, score = 4, grows = true }
weight is its share of the spawn roll against every other item;
score is its base value before the combo multiplier; grows is
whether eating it lengthens the snake.
Reference
The API your block receives
execute = function(ctx, inp, p)
inp holds your data inputs by id — an unwired input falls back
to its default. p holds your params by id. Everything else
hangs off ctx.
ctx.sensor
Directions are snake-relative
strings: "FORWARD", "LEFT", "RIGHT",
"BACK". Cell kinds are "wall", "body",
"apple", "empty", "exit", "door",
"switch".
| Call | Returns | Notes |
|---|---|---|
| touch() | { forward, left, right } | The three cells around the head, as cell kinds. Works in every direction at baseline. Needs a Sensor Segment. |
| look(dir, dist) | { kind, distance, solid } | Casts a ray and stops at the first cell that is not empty. Range is capped at 3 by the segment. Needs a Sensor Segment. |
| smell() | { found, dir, distance } | The nearest item within radius 5. found is false when nothing is
in range. Needs a Sensor Segment. |
| self() | { length, … } | Self-knowledge. Needs no hardware. |
| position() | { x, y } | Where the head is on the board. |
| tick() | int | Ticks elapsed since the run began. Needs no hardware. |
| random(n) | int | 0 to n−1, from the seeded generator. Always use this — see the sandbox note below. |
ctx.act
Calling any of these resolves the tick's one action and suspends execution at your block.
| Call | Effect |
|---|---|
| move() | Advance one cell in the direction the head faces. |
| turn_left() | Rotate 90° counter-clockwise without moving. |
| turn_right() | Rotate 90° clockwise without moving. |
| wait() | Spend the tick doing nothing. |
| wait_ticks(n) | Park the thread for n ticks. The extra ticks cost no ops. |
| stop() | Halt the program. Execution does not resume. |
ctx.mem, ctx.state and ctx.log
| Name | What it is |
|---|---|
| ctx.mem.get(i) | Read register i. Zero if nothing was written there. |
| ctx.mem.set(i, v) | Write register i. Registers are shared by the whole
program and persist across ticks. Baseline is 2 slots; each Memory Segment
adds 2. |
| ctx.state | A private table for this node instance, persisting across ticks. Use it
for counters and timers so they do not burn a shared register — this is how
Repeat N Times counts. |
| ctx.log(…) | Shows up in the in-game inspector. Your debugger. |
The sandbox
Your code runs in a whitelist. Available:
math (without random), string,
table, pairs, ipairs, next,
type, tostring, tonumber, select,
error, assert. Each file gets its own copies of
math, string and table, so overwriting
string.format in your mod cannot change anyone else's code.
| Absent | Why |
|---|---|
| io, os | Filesystem and process access. A mod is content, not a program. |
| load, dofile, require, package | Would route straight around this whitelist. |
| debug | Reaches into other environments and upvalues. |
| pcall, xpcall | Would let a mod swallow the watchdog error and loop forever. |
| setmetatable, getmetatable, raw* | Metatable surgery on shared values. |
| collectgarbage | Lets a mod stall the process legitimately. |
| math.random | Use ctx.sensor.random(n). |
| print, _G | No stdout, no global escape hatch. |
math.random is not there to reach for.
Part two
Levels
A level is a plain text file with the map drawn in it as characters. Yours go in
levels/custom/, and they appear in the game under
CUSTOM LEVELS on the title menu — a numbered grid exactly like
the puzzles, built from the directory and sorted by filename. Name them
01_…, 02_… if you want a particular order.
Drop a file in
levels/
custom/
01_my_test.level
02_another.level
Only files ending .level are picked up, so a README or a scratch file can
sit beside them harmlessly. The directory being absent or empty is the normal state —
the menu row reads CUSTOM LEVELS - NONE FOUND rather than the load
complaining. A broken level is named in the log and skipped, exactly like a broken mod:
one bad file never costs you the rest of them.
Shipped levels carry a source fingerprint and are reported as corrupted when edited.
Custom levels do not: like everything under mods/, they are author content
and stay freely editable.
Two rules that are not the format
Custom levels record their best ticks and fewest blocks like any other, but they sit outside the levels score — that total is only comparable because it runs over a fixed set of 100 shipped puzzles, and a two-block custom level would otherwise be free points on it.
And nothing here is locked. These are your levels; having to beat your own level 1 to reach your level 2 would be a rule invented for no reason.
A complete file
The fastest start is to copy anything out of levels/puzzles/ and edit it.
This one ships in levels/custom/ as a worked example — six cells of
corridor, solvable with a single block, because the chain re-runs every tick on its own.
levels/custom/01_my_test.levelcomplete file
# Six cells of corridor, one block wired once: the chain re-runs every # tick, so Move Forward alone walks it. id custom_test_a name My Test Level desc Six cells forward. One block, wired once. size 12 5 heading EAST length 3 seed 100 objective reach_exit bonus within_ticks 10 tick_limit 40 stall_limit 20 blocks start move_forward hint The chain re-runs every tick on its own. hint Start wired to Move Forward is the whole program. row ############ row #..........# row #..S.....E.# row #..........# row ############
Lines starting with # are comments. A byte-order mark on the
first line is stripped, so a file saved by an editor that adds one still loads.
Every key
| Key | Takes | Meaning |
|---|---|---|
| id | word | Unique id. Progress is saved against it, so renaming one orphans its records. |
| name | text | Shown in the level list and the header. |
| desc | text | One line under the name. |
| size | w h | Board dimensions. Must come before any row. |
| heading | NORTH… | Which way the snake faces at tick zero. |
| length | int | Starting body length, counting the head. |
| modules | list | One per segment behind the head, neck first: sensor, memory, processor. This is what gates the sensing blocks. |
| seed | int | The run's seed. Same seed, same run. |
| objective | kind [n] | A requirement. Repeat the key for more than one. |
| bonus | kind [n] | Same kinds, but optional — worth score rather than required. |
| objective_mode | all | any | sequence | How multiple objectives combine. Default all. |
| tick_limit | int | Fail after this many ticks. |
| stall_limit | int | Fail after this many consecutive ticks with no action — catches a program that can never reach one. |
| blocks | list | Which block ids the editor offers. This is the difficulty dial: the same map is a different puzzle with a shorter list. |
| hint | text | One line of the in-level hint panel. Repeat for more lines. |
| food | random | Spawn items randomly. A fixed item is placed with o on the map instead. |
| entity | see below | A switch or a door. |
| row | map | One row of the map. Must be exactly size wide. |
The map
| Char | Tile |
|---|---|
| # | Wall. |
| . | Floor. |
| S | Where the snake's head starts. |
| E | The exit. |
| o | An item. Every o places one, and authoring order is list order. |
Anything else fails the load with the character named. Switches and doors
are not map characters — they are entity lines, because they carry state
and links.
Objectives and bonuses
| Kind | Amount | Met when |
|---|---|---|
| reach_exit | — | The head reaches the E tile. |
| collect | n | n items have been eaten. |
| grow_to | n | The snake reaches length n. |
| survive | n | n ticks pass without failing. |
| within_ticks | n | The level is finished in n ticks or fewer. |
| max_blocks | n | The program uses n blocks or fewer. |
| activate_switches | n | n switches have been pressed. |
within_ticks and max_blocks are the two that make
good bonuses: they ask for a better program rather than a different one.
Switches and doors
entity gate door 9 4 start=closed entity plate switch 3 7 mode=once links=gate
| Option | On | Meaning |
|---|---|---|
| start=open|closed | door | Its state at tick zero. A closed door blocks; an open one reads as empty floor. |
| mode=toggle|once | switch | Whether stepping on it again flips it back, or it latches. |
| links=a,b | switch | Comma-separated entity ids this switch drives. |
What the loader rejects
Every one of these fails the load with a message naming what is wrong, so you find it rather than the player does.
| Rejected | Why it matters |
|---|---|
A row narrower or wider than size |
Padding a short row would quietly delete the right-hand wall, which is far harder to notice than a refusal to load. |
No S on the map |
There is nowhere to put the snake. |
Too little room behind S for length |
The body would spawn inside the wall. |
More modules than length - 1 |
Modules sit in the segments behind the head; the head carries none. |
| A switch linking a door id that does not exist | A switch that drives nothing is almost always a typo. |
| An entity on a wall, the exit, food, or the snake's start | Two things in one cell has no defined meaning. |
A blocks entry that is not a real block id |
Catches a renamed block and a misspelling in the same check. |
| An unknown key, or an unknown map character | Named in the error, with the character quoted. |
within_ticks bonus is
actually reachable. The shipped puzzles are proven solvable by a route search in
--selftest; that check deliberately does not run over
levels/custom/, because it is your folder and a work-in-progress level
should not fail the game's own tests.
Testing it
./build/snake_v2 --level levels/custom/01_my_test.level
That skips the menu and opens your level directly, paused at tick zero — quicker than restarting into the grid each time. Press space to run it.
Part three
Programs
Programs live in programs/*.graph and are plain text too — the editor reads
and writes the same format, so a graph is hand-editable and diffable. This is the
Perimeter solution written out.
entry begin node begin start node look wall_ahead node turn turn_right node walk move_forward exec begin out look exec look yes turn exec look no walk
Neither action wires its out anywhere. That is not an
omission: a chain that runs out of wire returns to Start on the next tick, so the loop
is already written. Wiring the actions back to look by hand would say what
the runtime already does.
| Directive | Syntax | Meaning |
|---|---|---|
| entry | entry <node> | Where the resumable thread begins. Exactly one. |
| on_tick | on_tick <node> | A second, re-entrant entry point that runs from its own top every tick. For reflexes. |
| node | node <id> <block> [k=v …] | An instance of a block, with its params. |
| exec | exec <from> <branch> <to> | A control-flow wire from a named branch. |
| data | data <from> <output> <to> <input> | A value wire between ports. |
| off | off <node> | Disable a node without deleting it. May precede its node line. |
| at | at <node> <x> <y> | Canvas position. Layout only — it never affects behaviour. |
| note | note <id> <x> <y> <text> | A comment pinned to the canvas. |
Running one headless
./build/snake_v2 --run-program programs/mine.graph --seed 1 ./build/snake_v2 --run-program programs/mine.graph --program-trace --ticks 40
The first prints survival, items eaten and peak ops. The second adds a line per tick showing which blocks ran, what they decided and the action taken — which is the fastest way to find the tick where a program goes wrong.