Modding

Block logic

Give a block its own behaviour in Lua. Components keep the state of every block, and batch_tick runs all blocks of a type once per tick. The examples target Evospace 0.22.2; the API reference documents version 0.22.2.

Block hooks

The examples belong to a mod with a block named Kiln. Its logic class is BlockLogic, so everything the block does comes from these fields of its StaticBlock, set in init:

FieldWhat it does
logic_init Called with the logic of every new block. Makes its inventories and accessors.
components Components every block keeps, with the values a new block starts with.
batch_tick Called once every sim tick with all placed blocks of the type.
window Builds the window of a block.
init.lua lua
local kiln_tick = require('@self/kiln_tick')

local Kiln = {}

local function kiln_init(logic)
    SingleSlotInventory.new(logic, "Input")
    SingleSlotInventory.new(logic, "Output")
end

function Kiln.init()
    Component.reg("Baking", { fields = { input = "item", progress = "integer" } })

    local kiln = StaticBlock.get("Kiln")
    kiln.components = { Baking = {} }
    kiln.logic_init = kiln_init
    kiln.batch_tick = kiln_tick.tick
end

function Kiln.post_init()
    local log, coal = StaticItem.get("Log"), StaticItem.get("Coal")
    kiln_tick.setup({
        bakes = { [log.sim_id] = { output = integer.create(coal.sim_id), ticks = integer.create(200) } },
        items = { [log.sim_id] = log, [coal.sim_id] = coal },
    })
end

db:mod(Kiln)

The kiln has an input slot and an output slot, and bakes a log into coal in 200 ticks. An inventory made with new(logic, name) is saved with the block, and both logic.inventories and batch_tick list it under that name.

Components

A component is a named set of typed fields. Register it in init with Component.reg, then list it in components of every block type that keeps it, with the values a new block starts with.

TypeHolds
integer A 64-bit whole number.
boolean true or false.
item An item or nil. Saved by the item's name.
string Text.
  • A field left out of the starting values starts at 0, false, nil or "".
  • Field names are Lua names. Two names that differ only in case are an error.
  • The fields of a component are fixed once a block type lists it.
  • A component can be listed by several block types, and a block type can list several components.

Lua reads and changes the components of a placed block through its logic:

FunctionWhat it does
logic:component(name) A copy of the component's fields, by name.
logic:set_component(name, fields) Changes the fields given. The rest keep their values.
logic:get_field(name, field) One field.
logic:set_field(name, field, value) Changes one field.

An item field takes an item, its name or nil. A window reads the state it shows with logic:component("Baking").

batch_tick

batch_tick lives in a file that starts with --!sim, written in the sim dialect of Luau: every number is a 64-bit integer. The game calls batch_tick(n, components, slots, inventories) once every sim tick with all placed blocks of the type, in position order. One call covers every kiln in the world.

kiln_tick.lua lua
--!sim
local bakes, items

local function setup(data)
    bakes, items = data.bakes, data.items
end

local function tick(n: number, components, slots, inventories)
    local input: column, progress: column = components.Baking.input, components.Baking.progress
    local slot_item: column, slot_count: column = slots.Input.item, slots.Input.count
    local inputs, outputs = inventories.Input, inventories.Output
    for i = 1, n do
        local item = slot_item[i]
        local bake = if slot_count[i] > 0 then bakes[item] else nil
        if not bake then
            input[i] = 0
            progress[i] = 0
        elseif input[i] ~= item then
            input[i] = item
            progress[i] = 0
        elseif progress[i] + 1 < bake.ticks then
            progress[i] += 1
        elseif integer.create(outputs[i]:add(items[bake.output], 1)) == 0 then
            inputs[i]:sub(items[item], 1)
            progress[i] = 0
        end
    end
end

return { setup = setup, tick = tick }
  • n is the number of blocks.
  • components.Baking.progress is the column of that field: progress[i] belongs to block i. An integer field holds its value, a boolean field 0 or 1, an item field the item's sim_id or 0 for none. String fields are left out; read them with get_field.
  • slots.Input.item and slots.Input.count hold every slot of every block, taken before the call. slots.Input.slots is the number of slots per block, and slot s of block i is at (i - 1) * slots + s. Each kiln inventory has one slot, so the kiln reads its input at i.
  • inventories.Input[i] is the Input inventory of block i, as its own class. Change items through it.
  • After the call the game writes the columns back into the blocks. Inside batch_tick, change fields through the columns: the write-back replaces what set_field wrote during the call.
  • The tables stay the same from tick to tick until a block of the type is placed or removed.

The sim file reaches the game only through the values it is given, so init.lua builds its tables in post_init, once every item has its sim_id.

The --!sim dialect

  • Every number is an integer, and a literal with a fraction is an error. Divide with //, which rounds down, and take the remainder with %; / and ^ are errors. Keep fractions in fixed point, such as thousandths in an integer.
  • The file sees two globals, integer and buffer. integer has min, max, clamp and the bit operations. Walk a table with for key, value in t do. Reading any other global, or assigning a global, is an error.
  • A local or a parameter declared : column reads and writes a column: progress[i] is the value of block i. Take each column into such a local first, and assign it in a statement of its own, as in progress[i] = 0 or progress[i] += 1.
  • A number from plain Lua or returned by a game function is a Luau number. Wrap it in integer.create before it meets an integer, as post_init does with sim_id and the kiln tick with the remainder of add. An integer used as a table key looks up the number, so tables keyed by sim_id in plain Lua work as they are.
  • sim_id numbers items for one session, and another set of mods shifts it. Keep items in item fields, which the save holds by name.

luau-lsp reads sim files with the definitions from Evospace/lua-api; see Editor for the .luaurc that accepts the directive.

Saves and versions

A block saves the fields that differ from its type's starting values, an item by its name. When a save names an item the game no longer has, the log shows a warning and the field becomes nil. A block replaced by another type on build keeps the components both types list.

To change the fields of a component that saves already hold, raise its version and give it migrate. Loading a block saved by a lower version calls migrate(fields, version) with the fields the save holds, by name with items as their names, and the version that wrote them. It returns the fields for the current version. Version 2 of Baking renames item to input:

init.lua lua
Component.reg("Baking", {
    fields = { input = "item", progress = "integer" },
    version = 2,
    migrate = function(fields, version)
        if version < 2 then
            fields.input, fields.item = fields.item, nil
        end
        return fields
    end,
})

The save holds only the fields that differ from the starting values, so a field can be missing from fields. When migrate fails, the log says so and the block keeps its starting values.

Errors

  • A sim file that breaks a rule of the dialect does not load, and the log names the file and the line.
  • The first error in the batch_tick of a block type goes to the log. The game keeps calling it every tick.
  • An item column holding a number that is no sim_id goes to the log once, and the field becomes nil.