DEV505

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

01

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.

02

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,
}
Prefix every id with your mod's name Ids are global and duplicates are rejected, not merged. Because mods load after the built-ins, a clash means your definition is the one that fails to load — deliberately, so a mod cannot silently replace a built-in block's behaviour.
03

The fields

FieldTypeMeaning
idstring Unique across the whole game. Prefix it with your mod name.
namestring Shown in the editor. Keep it short — the block header is narrow.
categorystring action, sensing, condition, flow, memory, source, event. Decides the header colour and where it sits in the list.
costint Ops. Charged against the player's 10-per-tick budget. Most blocks are 1; real sensing is worth 2.
modulestring? The body module this needs: sensor, memory or processor. Declare sensor on anything calling look, smell or touch.
paramslist Values edited on the block itself. Each is { id, type, default, min, max, options, desc }.
inputslist Typed data inputs pulled from other blocks' outputs. { id, type, default, desc }.
outputslist Typed data outputs other blocks can read. { id, type, desc }.
execlist Branch labels — { "yes", "no" }, or { { "yes", desc = "…" } } to document them. Its presence decides the block's shape.
exec_inbool Set false to refuse an incoming exec wire. Only Start does this.
descstring The help text. This is what the wiki and the in-game info panel show.
executefunction 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.

04

The three shapes

Shape is derived from the definition, not declared, and it is the easiest thing to get wrong.

ShapeexecBehaviour
Branchingpresent, non-empty Exec in, one labelled exec out per entry.
Terminalpresent, empty {} Exec in, nothing after. Ends the program — only Stop does this.
Sourceabsent No exec ports at all. Never in the control flow; runs only when a data wire reads it.
Omitting exec does not mean "ends the tick" It makes a source node, which will never run in the control flow. If you meant "this ends the tick", write exec = {}. A source node must also declare at least one output, or nothing can ever reach it — the loader rejects that.
05

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,
}
One action per tick, and the tick ends there Calling two actions in one 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.
06

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.

07

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".

CallReturnsNotes
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.

CallEffect
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

NameWhat 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.

AbsentWhy
io, osFilesystem and process access. A mod is content, not a program.
load, dofile, require, packageWould route straight around this whitelist.
debugReaches into other environments and upvalues.
pcall, xpcallWould let a mod swallow the watchdog error and loop forever.
setmetatable, getmetatable, raw*Metatable surgery on shared values.
collectgarbageLets a mod stall the process legitimately.
math.randomUse ctx.sensor.random(n).
print, _GNo stdout, no global escape hatch.
Never bring your own randomness The whole game replays from a seed — same seed, same run, every time. A second source of randomness breaks that, which is why math.random is not there to reach for.
A broken block never takes down the game A mod that throws while loading is disabled and named, with the Lua line number; every other mod carries on. A block that throws while running ends that tick with the error in the inspector. Separately, every chunk runs under an instruction watchdog, so an infinite loop kills your block rather than the app — and the player is never charged for it.

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.

The folder is read once, at launch Blocks hot-reload while the game runs; levels do not. Restart after adding or renaming a file. Editing the program you are solving it with still reloads live, so the iteration loop inside a level is unaffected.
01

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.

02

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.

03

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

KeyTakesMeaning
idwordUnique id. Progress is saved against it, so renaming one orphans its records.
nametextShown in the level list and the header.
desctextOne line under the name.
sizew hBoard dimensions. Must come before any row.
headingNORTH…Which way the snake faces at tick zero.
lengthintStarting body length, counting the head.
moduleslistOne per segment behind the head, neck first: sensor, memory, processor. This is what gates the sensing blocks.
seedintThe run's seed. Same seed, same run.
objectivekind [n]A requirement. Repeat the key for more than one.
bonuskind [n]Same kinds, but optional — worth score rather than required.
objective_modeall | any | sequenceHow multiple objectives combine. Default all.
tick_limitintFail after this many ticks.
stall_limitintFail after this many consecutive ticks with no action — catches a program that can never reach one.
blockslistWhich block ids the editor offers. This is the difficulty dial: the same map is a different puzzle with a shorter list.
hinttextOne line of the in-level hint panel. Repeat for more lines.
foodrandomSpawn items randomly. A fixed item is placed with o on the map instead.
entitysee belowA switch or a door.
rowmapOne row of the map. Must be exactly size wide.
A short row is a load failure, not a pad Padding one quietly would delete the right-hand wall, which is much harder to notice than a refusal to load.

The map

CharTile
#Wall.
.Floor.
SWhere the snake's head starts.
EThe exit.
oAn 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

KindAmountMet when
reach_exitThe head reaches the E tile.
collectnn items have been eaten.
grow_tonThe snake reaches length n.
survivenn ticks pass without failing.
within_ticksnThe level is finished in n ticks or fewer.
max_blocksnThe program uses n blocks or fewer.
activate_switchesnn 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
OptionOnMeaning
start=open|closeddoorIts state at tick zero. A closed door blocks; an open one reads as empty floor.
mode=toggle|onceswitchWhether stepping on it again flips it back, or it latches.
links=a,bswitchComma-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.

RejectedWhy 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.
Two things the loader cannot check That the level is solvable, and that a 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.

DirectiveSyntaxMeaning
entryentry <node>Where the resumable thread begins. Exactly one.
on_tickon_tick <node>A second, re-entrant entry point that runs from its own top every tick. For reflexes.
nodenode <id> <block> [k=v …]An instance of a block, with its params.
execexec <from> <branch> <to>A control-flow wire from a named branch.
datadata <from> <output> <to> <input>A value wire between ports.
offoff <node>Disable a node without deleting it. May precede its node line.
atat <node> <x> <y>Canvas position. Layout only — it never affects behaviour.
notenote <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.