Skip to content

5. Syntax

One line is one command, and # begins a line comment. The line starts with a command keyword; every remaining argument MUST be key=value.

window side=front mat_slot=glass offset=2 y=2 size=2x2 sym=true # OK
window front G 2 2 2x2 # forbidden (positional args)

Positional arguments would mean remembering argument order, which an LLM hallucinates and omits. Keys like mat= and side= act as attention anchors and stabilize generation, which is worth more than the tokens they cost.

A bare value on a line that reads none is E_UNEXPECTED_POSITIONAL. connect FROM.PORT to TO.PORT is the one form with a reader for positionals, and its shape is checked by E_CONNECT_ARITY instead.

The parser puts anything that is not key=, -> binding, or [selector] into the positional list, so a dropped = lands there too. walls mat_slot=wall height 3 is not a walls with a shortened height. It is not built at all.

A size literal is exactly two extents, WxH. A run that continues past the second, such as 2x2x9 or 2x2y, is refused at the literal rather than read as a size followed by something else.

Commas are optional separators, not structure. mat=[a, b] and mat=[a b] name the same two items, as do [side=front, y=2] and [side=front y=2]. The two list kinds differ in how much punctuation they tolerate:

  • A [selector]’s attribute list skips a comma wherever it finds one, so [side=front, , y=2] parses.
  • A value list reads at most one comma between items and refuses [a, , b].

A canonical token may carry a block-state literal, as in @oak_log[axis=x] or @oak_stairs[half=top, facing=north]. The [ must touch the token. After a space it is whatever comes next, so in a value list [@a [b]] is still a token and a nested list, while [@a[b]] is refused because b is not a property. Each pair inside is property=value, where the value is a word, a run of digits, or true / false. This is Minecraft’s own block-state syntax rather than a Cairn list, so exactly one comma separates two pairs. An empty literal, a trailing or doubled comma, and a property named twice are refused. A dotted token such as @floor.wood is abstract and takes no literal, because the theme that binds it chooses the block, so a [ touching it is whatever comes next, as one after a space is.

Before the literal, a [ touching an undotted token was whatever came next too, so mat=@a[1] and mat=[@a[b]] used to parse and are now refused. No source that passed cairn check had either shape: a bare value on a line that reads none is E_UNEXPECTED_POSITIONAL, and a list where a label belongs is E_TYPE_MISMATCH_LABEL. Only the parse tree of a source that could not build changes.

The literal’s properties and values are not yet checked against the target: E_STATE_DOMAIN (Versioning and Editions) is not implemented, so a Java build writes @oak_log[axis=q] as written. Each literal the build reads earns a W_STATE_LITERAL_UNCHECKED (Lint) on the token, so that is said rather than silent.

Besides that literal, the one place a comma carries meaning is the input list of assert truth(...), where it separates the signals whose count the row width is checked against. A row writes one character per input signal — 0, 1, or - — so truth(a, b -> out) takes rows two characters wide and refuses { 2->0 } or { 0->0 }.

- is a don’t-care: the row means every value of that input, so 0- -> 1 says what 00->1 and 01->1 say together. It is a shorthand for those rows and not a construct of its own, which is why two rows may not both stand for one combination — see the table below.

- and -> share a character, and the lexer takes the arrow whenever it can. A row whose last input is a don’t-care is therefore written 11--> 0 or 11- -> 0; both are the same three-wide row. Whitespace ends a pattern, so 0- 1 -> 1 is a two-wide pattern and a stray 1, not a three-wide row.

A row’s output is 0, 1, or - as well, and there it means something else: the row’s combinations are deliberately unconstrained. --0 -> - says the table has nothing to say about any combination with a low third input, which is what answers W_TRUTH_TABLE_PARTIAL without asserting four outputs the author does not mean. The arrow is already read by then, so -> - and ->- are the same row. A table every row of which has a - output constrains nothing and is E_TRUTH_TABLE_EMPTY, the same as a table with no rows.

The table around those rows is read the same way:

Case Code
No rows at all, or no row with a 0 or 1 output E_TRUTH_TABLE_EMPTY
Two rows assign one input combination different outputs E_TRUTH_TABLE_CONFLICT (on the later row)
Two rows cover one input combination without contradicting each other W_TRUTH_TABLE_DUPLICATE_ROW (on the later row)
Some input combinations are unassigned W_TRUTH_TABLE_PARTIAL

The last two are warnings because the rows that are present still assert what they say. A four-input table is sixteen rows, and an author part way through is not blocked.

Indentation is two spaces per level and opens one level at a time. A width that is not a multiple of two and a jump of more than one level are different mistakes and are reported as such.

Spaces before a line break are not part of the line, so a row may end in them, and a line holding nothing but spaces is a blank line. A blank line’s leading spaces are counted like any other line’s and then discarded with the line, so their width is neither an indent nor a mistake.

A UTF-8 byte-order mark at the very start of a file is ignored; one anywhere else is an ordinary stray character.

A line ends at \n, at \r\n, or at a lone \r, and all three are the same line break. VS Code and Monaco use the same rule, so a diagnostic’s line number and the line under the cursor name the same row. A position always points at the text that is wrong, so an error at the end of a line is reported there and never at the first column of the next one.

The tree-sitter grammar is a known exception: its runtime advances the row on \n alone, so a file terminated only by lone \r highlights as one long line even though it parses correctly.

Keep nesting shallow: struct / def / level / theme / site. Deep nesting increases LLM generation errors. (room is not on this list; it is still open, so writing one today is E_UNKNOWN_KEYWORD. See Open Issues.)

Inside a body, level y=N is the only member that groups other members, and only in a struct or a def. A site body is a flat list of place and connect rows with no grouping construct at all. An indented body anywhere else is E_UNSUPPORTED_NESTING rather than a silent drop. It lowers to nothing, places nothing, and lays no walkway.

That rule is about members, and reaches only bodies that hold them. A theme body holds rules, which bind materials and open nothing, so a line indented under one is a syntax error and not a nesting diagnostic. So is a line indented after a directive: a directive is one line, and the line under it belongs to no construct.

Compilation Model §4.7 defines what y=N means to each grouped member.

Which keywords a body accepts follows the same split. A struct / def body describes one building’s geometry: floor, walls, door, window, roof, stair, level, pressure_plate, circuit. A site body describes a layout: place, connect. The keyword table is global, so writing one in the other body parses and classifies and then reaches nothing. That is E_MISPLACED_MEMBER, reported once at the offending row, taking anything indented under it along.

logic and assert lines are not members, so the rule does not reach them. A logic line is read by redstone synthesis from either body, and an assert is read by nothing yet.

Top-level names are scoped per kind. theme / def / struct / site are four namespaces, so one name may appear once in each. Declaring it twice within one kind is E_DUPLICATE_ITEM. For theme / def / struct the name is what binds, so the first declaration resolves and the repeat would otherwise vanish without a signal. Two site blocks of one name merge instead, sharing one site::NAME:: namespace, but east_of= cannot reach across the blocks. The merge is half a merge, and still an error.

Metadata MAY go in headers rather than in the semantic body:

@cairn 2026.06 # the Cairn language version this file was written against
@requires version>=1.20 # capability floor on the Minecraft target
@intended_targets ["1.20.4","1.21.4"] # a hint, not a verification record

@cairn is the version of the Cairn language itself, a separate axis from the two Minecraft headers. It is optional and exists as provenance, so a future compiler can parse and warn correctly. No pass branches on the value, but it is read: YYYY.M or YYYY.M.PATCH, with a four-digit year and a month 1 … 12. A leading zero on the month is accepted, so 2026.06 and 2026.6 are one version. Anything else is W_INVALID_CAIRN_VERSION, and a version later than the compiler reading it is W_FUTURE_CAIRN_VERSION — provenance that cannot be read by a later compiler is not doing the job the header exists for.

@requires is a capability floor. Its expression is an optional edition, the subject version, the operator >=, and a version label, with whitespace optional between them, so version>=1.21 and version >= 1.21 are one requirement. >= is the only operator, since a floor is the only constraint that composes by folding to the strictest. Every other expression is E_INVALID_REQUIRES rather than a line that quietly declares nothing: a floor that evaporates is worse than an absent one, because a reader will still believe it.

@requires version>=1.21 # a floor on whichever edition is built
@requires java version>=1.21.4 # a floor in Java's numbering, inert on a Bedrock build
@requires bedrock version>=1.21.40

The edition is there because Java releases run 1.20.4 / 1.21 / 1.21.4 and Bedrock 1.21.0 / 1.21.40 / 1.21.60: 1.21.4 is Java’s newest release and names no Bedrock release at all. A label is dot-separated components that each begin with a digit and carry only letters and digits, with an optional - and a pre-release tag of the same — 1.21.4, 1.21.4-rc1, and 24w14a are all labels. Which of them the target edition can order is not a syntax question and is not answered here. See Versioning and Editions.

@intended_targets says which Minecraft versions the file was designed for. It is not a claim of being verified. That record lives only in the lock.

A hint is still weighed against the floor beside it. A file whose @requires refuses every version its @intended_targets names that the target edition can build states an intention the compiler will refuse the moment anyone acts on it, and that is E_INTENDED_TARGET_CAP; a list only partly below the floor is W_INTENDED_TARGET_CAP, and a version the target edition cannot build at all is W_INTENDED_TARGET_UNSUPPORTED. See Versioning and Editions §10.4.

@cairn and @intended_targets appear at most once per module, and a repeat is E_DUPLICATE_HEADER. @requires is the exception: its floors compose, so repeating it adds a constraint rather than displacing one.

A def or a theme may carry the same expression as a body line, spelled requires without the @ — a floor on that part rather than on the file, inherited by every build that instantiates it. The sigil is what marks a file directive, and a part’s floor is not one. See Versioning and Editions.

Wall selectors are front (+z), back, left, and right. offset runs along the wall, and y is measured from the floor (y = 0). Inside faces are prefixed: inside.front. Blocks, block entities, and entities all use one selector grammar.

offset origin. offset=0 sits at the wall’s left end viewed from outside that wall. The front and back walls anchor at low x, front from the +z viewpoint and back mirrored along x so a sym=true opening looks symmetric from either side. The left and right walls anchor at low z and mirror the same way.

sym=true mirrors the opening across the wall’s midpoint (mirror_offset = wall_length - offset - size_w). A mirror overlapping the primary rectangle is rejected with W_DEFERRED_MEMBER, and only the primary is painted. sym= takes a bare true or false, and a window without it is not mirrored. Any other value (sym=yes, sym="true", sym=1) is an unreadable value: the window is drawn without its mirror, and the value is reported as W_IGNORED_ARGUMENT (Lint §11.3).

at= door anchors. A door’s wall-local column comes from one of three named anchors:

Anchor Column
at=center wall_length / 2, rounded half up. Odd walls have a unique centre; even walls pick the column right of the midpoint.
at=left The wall-local axis origin, u = 0.
at=right The far corner, u = wall_length - 1.

The same column resolves both the openings cut and any connect walkway anchored to this door (§9.3.5). Numeric offsets (at=N) are reserved for a future extension.

Which rows a door opens. A door under level y=N opens at row N + 1, the row above that level’s base plane, and takes the two rows a doorway wants, or as much of that row’s wall course as it has counting the row it opens at — so a door under a one-row course opens that one row rather than cutting into the roof. The row it opens at MUST be inside a course of the masonry; a door written against walls that do not reach it is W_DEFERRED_MEMBER and cuts nothing, the same finding the window on that body earns (§9.3.5 states the courses a walls paints).

Important members MAY declare id=, and class= groups members. Members without an id= get a stable, meaning-based address assigned by the compiler, derived from parent / role / side / level / offset. See Components, Editing, and Multi-building §9.2.

A place row is the exception: its id= is required, and omitting it is E_INCOMPLETE_PLACE. An auto-address names nothing outside the body it sits in. A place’s id= is what east_of= and connect refer to, and what its .nbt is written under, so an invented one would be a name the author never wrote and cannot point at.