Log inGet started

Authoring content other people can use

Updated 5 September 2026

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 InventoryRef type, so a function returning one can say so and the checker knows what it has,
  • each member's documentation, in man and lsp.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.