Every error carries a file:line pointer. Warnings don’t stop the build; errors do.
| Message | Cause / fix |
|---|
expected "=== fragment_id" before content | Prose or directives before the first === line. |
fragment id "…" must be snake_case | Ids are lowercase letters, digits, underscores, starting with a letter. |
duplicate fragment id | Two fragments share an id (in one file, or across files - the cross-file check runs in npm test and at load). Rename one. |
defines N fragments … a shipped file holds exactly one | Split them, one file per fragment. Only files the build ships are checked, so an underscored draft can hold as many as you like. |
holds fragment "…", so it should be named "….frag" | The filename and the id have to agree. Rename whichever is wrong. |
unexpected line in fragment header | Inside the header (before the first blank line) only phase:, title:, tags:, requires: are allowed. Usually a typo’d field name, or a missing blank line before the prose. |
missing "phase:" / missing "title:" | Required header fields. |
unknown phase "…" | phase: must be one of the six life phases. |
expected "@ subscene_id" or "===" | Loose content after a fragment’s scenes ended. |
duplicate subscene id | Two @ scenes with the same name in one fragment. |
| Message | Cause / fix |
|---|
"*requires hidden:" is one word now | Use *requires_hidden:. One directive is one token, so there is no whitespace inside one. |
"==" is not an operator here | Conditions compare with a single =. There is no assignment inside a condition to tell it apart from, so there is no second spelling. |
cannot parse condition term "…" | The term matches no known form. Check the grammar. |
cannot parse check "…" | Dice checks look like random(20) + VIT >= 70. |
random(N) needs at least 2 sides | A one-sided die isn’t random. |
random() is not allowed in inline {if} | Inline text re-renders; roll in an *if block or *set: x = random(N) first. |
unknown stat "…" | Stat codes are INT CHA VIT WIL WEA REP HAP MOR - anything else uppercase is a typo. |
stat "…" needs a comparison | Bare uppercase names aren’t conditions; write INT >= 50. Bare lowercase names are variable checks. |
dangling "&&" | Trailing operator with nothing after it. |
| Message | Cause / fix |
|---|
cannot parse effect "…" | Each term must be STAT+N or STAT-N. Variables don’t go in *effects: - use *set:. |
stat "…" appears twice | Merge the deltas into one term. |
cannot parse assignment "…" | *set: takes name, name = true/false, name = <int>, name = random(<sides>), or name += <int> / name += random(<sides>). |
"…" is declared as a flag, and counting … is not something a flag does | += and -= count, so they need an integer or random(N). Plain = is what sets true/false. |
"*unset:" takes only variable names | No = in *unset: - it erases; to assign, use *set:. |
invalid variable name "…" | Variables are snake_case. Uppercase means you wanted *effects:. |
"age" is reserved | age and played can’t be variables. |
| Message | Cause / fix |
|---|
"…" is not declared in this fragment | Every variable you touch goes in a *use block (shared state somebody else made) or a *declare block (state you’re introducing). Usually a typo: without declarations a misspelled name reads as 0 and its branch silently never fires, which is why this is an error. |
no shared variable by that name exists - did you mean "…"? | The name in your *use block isn’t registered. Take the suggestion, or ask in your submission thread if you’re introducing it. |
"…" is declared as a flag, and … is not something a flag does | Types are enforced. += and >= are for a count; = <member> is for an enum; true/false is a flag. |
"…" is not a member of enum "…" | Set it to one of the members you listed, or add the member to the declaration. |
only a local or an innate may declare a value | Shared state starts unset, because no fragment can know what ran before it. Mark it local, make it innate if the character is born with it, or drop the =. |
innate "…" needs the value it is born with | An innate is settled at birth, so the declaration has to say what it settles on: = random(6) for a count, = chance(10) for a flag, or a constant. |
"…" is declared both local and innate | The two disagree about who the fact belongs to. An innate nobody else can read is a local with a default, which already works. |
"random(6)" rolls a number, so it belongs to a count | One roll per type: random(N) spreads a count over 1..N, chance(N) makes a flag true in N lives per hundred. |
chance(100) must be between 1 and 99 | For always or never, say true or false and read as if you meant it. |
innate "…" is random(6) here and random(20) in … | Two fragments declaring the same trait have to declare the same one. A life cannot be born on two different dice. |
unterminated "*declare" block | Add the *enddeclare (a *use block wants *enduse). The line number is where the block opened. |
a fragment has one "*use" block | Merge them; same for *declare. |
"…" is named twice in this fragment's header | One name means one thing per fragment, so a variable and a world entity cannot share one. Rename whichever reads worse. |
| "…" is not an attribute of a character | Attributes are name: and note:. An unrecognised one would be written and never read, so it is refused rather than dropped. |
| attributes are "key: "value"" pairs separated by commas | Every attribute value is quoted: { name: "Kevin", note: "loyal, reckless" }. |
| no character or anything else by that name - did you mean "…"? | The name in your *use block is not in content/world.json. Take the suggestion, or say in your submission thread that you are introducing them. |
| "…" is a character here, but content/world.json has it as a place | Two different things are sharing an id. The registry is what every other fragment reads. |
| Message | Cause / fix |
|---|
choice depth jumps from N to M | Nest one level at a time: > then >> then >>>. |
"*requires:" must directly follow its choice line | *requires: lines go immediately under the > line, before any prose or effects. |
… can offer a player nothing but locked doors | Every choice the scene presents is gated, so a character who meets none of the gates sees a wall and a Continue. Leave one ungated, or add *exhausted: to say where they go instead. A choice that only exists inside an *if does not count, since the arm can fire alone. |
fragment "…" never offers the player a choice | The entry has no choices and nothing it can reach with -> has any either. The choices do not have to be in the entry: an entry may hand over to a subscene that holds them. Somewhere down the line, the player has to get to pick something. |
| Message | Cause / fix |
|---|
unclosed "*if" block starting at line N | Add the missing *endif. |
"*endif" has no "*if" block to close | A closer with nothing above it to close - the opener is missing or was deleted. Same for *endrandom, *enduse, *enddeclare, and for a stray *else. |
"*end" is not a closer | *end closed every block once and closes none now. Each block names what it ends: *endif, *endrandom, *enduse, *enddeclare. |
"*endif" is the closer for "*if" - this block closes with "*endrandom" | The right closer, on the wrong block. Reported at the closer, which is the line that is wrong. |
"*random" block with a single "*chance" arm | Randomness needs at least two arms. |
unclosed "*random" block starting at line N | Add the missing *endrandom. |
"*chance" arm outside of a "*random" block | Arms only live between *random and *endrandom. |
cannot parse "*chance …" - expected "*chance <weight>" | Weights are positive whole numbers. |
nav target "…" does not match any subscene | -> targets must be @ scenes in the same fragment (or death). |
conditional navigation is gone | -> [cond] target was shorthand for an *if block holding a nav. Write the block; the message shows it. |
unreachable nav | A -> above it always wins, so this one can never run; reorder or delete. An error rather than a warning like other dead content, because a jump that never fires is invisible in play: the player simply ends up somewhere else. |
"*exhausted:" target "…" does not match any subscene | Like a -> target, it must be an @ scene in the same fragment (or death). |
"*exhausted:" needs a target subscene / target "…" contains whitespace | One snake_case id, e.g. *exhausted: leaving. |
a scene can have only one "*exhausted:" line | Merge them; a scene has a single way out when it runs dry. |
"*exhausted:" belongs to the scene itself | It was inside an *if/*random block. Move it to the scene’s top level. |
| Message | Cause / fix |
|---|
unclosed {if} | Every {if …} needs its {endif}. |
"{endif}" without an opening "{if}" | Stray closer - check nesting. |
unknown inline token "{…}" | A token must be a conditional, a pronoun token, or a value read (a stat like {WEA}, {age}, or a snake_case variable). Mixed case, spaces, and hyphens are none of those. |
"{@…}" is gone | There is no token for a name. Write it: a character called Kevin is called Kevin. The *use or *declare line is what records that your fragment features them. |
"…" needs a "description:" | A *declared world entity has to say who or what it is. That sentence is what goes into the registry and what the next writer reads. |
"…" already exists - … introduced it | *declare is for state or people nobody has yet. Move it to a *use block. |
"reserved" on … takes true or false without quotes | It is a fact, not a phrase, and "false" in quotes would read as true. |
a transient looks like "*tmp count i = 0" | A *tmp always says what it starts as, because it starts fresh on every run. |
unterminated block comment | A \* fence was opened and never closed. The line number is where it opened. |
closes a block comment that was never opened | A *\ with no \* above it. |
block comment fences go on their own lines | \* … *\ written on a single line. Put each fence on its own line. |
unknown stat "{…}" in prose | An all-caps value token must name a real stat: INT CHA VIT WIL WEA REP HAP MOR. |
*if, *set, *beat, @@, variant:, followup:, and - sub-choices are from
the pre-2026 format. The error message names the v2 replacement; the full mapping is
in the repo’s docs/frag-format.md under “Migrating from v1”.
| Warning | Meaning |
|---|
has no tags | Selection diversity uses the first tag; untagged fragments opt out. |
remove the "author:" line | You do not sign your own fragments. Authorship is written when a submission is accepted, from the handle you registered under, so delete the line. If you copied it from an old example, that example is out of date. |
choice "…" has an empty body | Selecting it does nothing visible and ends the fragment. Write the outcome, send it somewhere with ->, or delete the choice. |
effect … is a large swing | Fires above 16. Allowed, but should be a deliberate, rare event. The style guide advises staying within 10, so most of the range between is quiet on purpose rather than endorsed. |
subscene "@ …" is never navigated to | Dead scene - delete it or wire it up. |
… puts … after a "->" | A -> ends the body it stands in, so nothing below it in the same body ever runs. Most often a choice written under a scene’s ->, which then never appears. Move it above the ->, or into the subscene the -> points at. A -> inside an *if arm does not strand what follows the block, and is not warned about. |
… can be revisited, and "…" fires again on every visit | Stat effects at the top level of a hub re-fire on every loop. Gate them behind *if visits = 1, or move them into a choice. |
sticky choice "…" … grants stats every time it is taken | A >* choice in a loop is farmable. Drop the *, or gate the reward. |
prose reads variable "{…}" but nothing in this file sets it | Usually a typo, since an unknown snake_case token prints 0 rather than failing. Harmless when another file sets the variable. |