Authoring content other people can use
The rules are short. There are four, and they compose.
1. A callable is a typed function
Write typed function, not function, for anything another file calls.
typed function M.damage(target: entityRef, amount: number): number
...
end
You get two things a plain function cannot give:
- The argument types are enforced at runtime. A caller passing a string where a number belongs is refused by name and position, at the call, instead of failing somewhere inside your body with a message about a value the caller never wrote.
- The checker knows the signature. A wrong-typed or wrong-arity call is an error before the code runs, and the editor completes the parameters.
A plain function is right for a module-local helper nothing outside calls.
2. A callable says what it is, in directives
Above the function, in --! directives:
--!desc Apply damage, returning the health left.
--!arg target The entity taking the damage.
--!arg amount How much to take off, clamped at zero.
--!return The target's remaining health.
--!example local hp = combat.damage(enemy, 10)
typed function M.damage(target: entityRef, amount: number): number
This is not decoration. It is the only thing that puts your function into
man, lsp.describe, lsp.search and the module's own summary. A function
with no --!desc is reachable only by someone already reading your source,
which means it is reachable only by someone who already knew it was there.
--!deprecated <what to use instead> marks a path that still works but is no
longer the one to take, and the message names the replacement.
3. A value is a Field.<kind>, and it carries its description
Component public data, and any other declared value, is built with a Field
constructor rather than a bare literal:
public = {
speed = Field.number(60, NoSync, "Metres per second at full throttle."),
target = Field.entityRef(nil, Sync, "Who this turret is tracking."),
}
The last argument is the field's description, and it is the same kind of
obligation as --!desc on a function: it is what the inspector shows, what
man prints, and what tells the next person whether speed is metres per
second or a multiplier. A field with a default and no description states a
number and hides its meaning.
It is the last argument, not the third — the kinds do not all take the same
ones before it. Field.enum(values, default, mode, …) and
Field.range(min, max, default, mode, …) take their own first, and
Field.alias(target, description) takes no mode at all, so its description is
the second. Read the constructor in modules/field.module before adding one.
The trailing argument also accepts Serialized, or a
{ serialized = ..., description = ... } table when you need both.
Field is not only for components. An assetType declares its properties the
same way, and gets the same inspector and the same documentation from it.
4. A surface that is not a function declares its members
The three rules above cover a module that exports functions and a component
that declares data. They do not cover a handle — a table you hand back
whose members are reached through a metatable, where there is no function
statement to write a directive above and no public table to put a Field in.
--!members <TypeName> covers that case. It marks a table as the description
of a surface's members:
--!members InventoryRef
local MEMBERS = {
weight = { kind = "property", type = "number",
doc = "Total mass carried, in kilograms." },
add = { kind = "method", type = "(self, item: string, n: number?) -> boolean",
doc = "Put an item in. False when it would not fit." },
slots = { kind = "namespace", type = "{ [string]: any }",
doc = "The per-slot surface." },
}
kind is property (read as a value), method (called — type is the whole
signature, self first), or namespace (carries its own members). doc is the
description. deprecated names a replacement.
From that one table the engine takes three things that would otherwise be written separately and drift apart:
- the member set the runtime accepts, so a misspelling is refused and the refusal lists the real names,
- the
InventoryReftype, so a function returning one can say so and the checker knows what it has, - each member's documentation, in
manandlsp.describe.
If your handle is a plain table whose methods are ordinary functions, you do
not need this — rules 1 and 2 already describe it, and an export type names
it. Reach for --!members when the members are dispatched rather than defined.
What this buys, concretely
A creator writing against a surface that follows these rules gets a misspelled member refused with the correct name offered, a wrong-typed argument refused at the call, and the ability to read what a thing does without opening the file that defines it. A surface that follows none of them is usable only by reading its source, and is therefore not reusable at all.
The engine's own library is written this way. When you are unsure what a rule
looks like in practice, read a neighbour: modules/api/engine/ for typed
functions and directives, any components/ asset for Field, and
modules/api/engine/entity.module/members.module for a member table.
Checking your work
lsp.check("<path>")on the file — the diagnostics a caller would get.lsp.checkAll({ scope = "user" })— the same across everything you authored.man <your surface>— what a reader actually sees. If it is empty or says less than you expected, the directives are missing, not the reader.