Table of Contents

Unquoted Keys

Note

Status: implemented. A key may drop its quotes wherever a sigil follows it — in a condition (?) and a random-choice weight (%). Unquoted is the recommended default; quotes are the escape.

Table of contents

Goal and scope

A quoted key — `"IsAngry"?`, `"Luck"%` — reads unnaturally to a writer: a key is a plain question or phrase, and the quotes are punctuation to remember and balance. So a writer may drop the quotes where it is safe:

`IsAngry?` => [The guard blocks you](#blocked)

The quoted form stays valid, and quotes stay required wherever dropping them would be ambiguous.

Scope:

  • Accept an unquoted key in the two sigil-terminated forms: a condition (?) and a weight (%).
  • Keep the value read (`"key"`) quoted, since it has no terminator.
  • Keep quotes as the escape for a key that must contain a trailing ? or %.

Out of scope:

  • Unquoted value reads — deliberately excluded; see D1.
  • Unquoted command names or arguments — a command already names itself with parentheses; this note does not touch the game-call grammar (see D4).
  • Dropping the code span — a key still lives inside a code span; a bare IsAngry? in prose is not a condition. The backtick is what marks the span as game-state, not speech.

Ubiquitous language

Term Meaning
Key The opaque string the game resolves — the argument of a query.
Quoted key A key written in straight double quotes: "IsAngry".
Unquoted key A key written without quotes: IsAngry.
Sigil The trailing operator inside a code span: ? (condition) or % (weight).
Value read A query that inserts the key's value into speech (`"key"`); has no sigil.
Condition A boolean read that guards a jump, line, or choice (`key?`).
Weight A dynamic random-choice weight computed from a key (`key%`).

The sigil is the pivot of this note: it terminates the key and names the operation, so the key before it needs no quotes to mark where it ends.

Writer-facing behavior

A key may be written quoted or unquoted wherever a sigil follows it:

Form Quoted (the escape) Unquoted Reads
Condition `"IsAngry"?` `IsAngry?` key IsAngry
Weight `"Luck"%` `Luck%` key Luck
Value read `"Alice.FavoriteColor"` — always quoted key Alice.FavoriteColor

The unquoted key is everything before the sigil, with surrounding whitespace trimmed and spaces inside kept — so a natural phrase works:

`Is Alice happy?` => [She smiles and waves you in](#welcome)

Quotes are the escape. To make a key that itself ends in ? or %, quote it, so the sigil after the closing quote is unmistakably the operator:

`"Rainy?"?` => [Wait out the storm](#the-inn)

Here the key is the literal Rainy? and the trailing ? is the condition. Without the quotes, `Rainy?` would read the key Rainy.

Recommendation. Prefer the unquoted form for a condition and a dynamic weight — it reads as a natural phrase and is the form this guide uses by default. Reach for quotes only to escape: a key that ends in a literal ?/%, a key that contains a ", or a value read (which is always quoted).

Grammar

(* A key is quoted or unquoted; only a quoted key may contain the sigil. *)
Key         = QuotedString | UnquotedKey ;
UnquotedKey = NonSigilText ;  (* trimmed, non-empty; may contain spaces *)

Condition = "`" , Key , "?" , "`" ;
Weight    = "`" , [ Number | Key ] , "%" , "`" ;   (* empty => auto weight *)
Query     = "`" , QuotedString , "`" ;             (* value read: quoted only *)

Number wins over Key in a weight (see D3). A value read has no sigil, so it admits only a QuotedString.

Key resolution

Recognition already strips the sigil and hands the remaining text to QueryKeyReader.Read, which resolves it — "a quoted string yields its inner text; any other non-empty text is an unquoted key":

codeSpan content
  └─ trim, split off trailing sigil (? or %)
       └─ remaining text ──► QueryKeyReader.Read
                               ├─ a clean "..."   → the inner text (quoted key)
                               └─ any other text  → the text itself (unquoted key)

The same QueryKeyReader.Read serves the condition and the weight, so the two forms accept keys identically. A value read never calls it — it keeps its quoted-string grammar.

Prior art

Language State reference Lesson
Yarn Spinner <<if $spoke_to_guard>> A $-sigil variable is unquoted; quotes are for string literals.
Ink {knows_about_wager: ...} A condition names a variable unquoted, no delimiters.
Ren'Py if angry: Python identifiers — unquoted variable names.
TOML bare-key = 1 vs "quoted key" = 1 Unquoted for the common case, quoted to escape special characters — the closest structural analog.

Two takeaways shape this note. First, every dialogue engine references game state with an unquoted name; a quoted variable is the unusual choice, which is why writers balk at it here. Second, TOML shows the clean split we adopt: an unquoted key for the ordinary case, a quoted key to escape characters the unquoted form cannot express.

DialogueDown diverges from the engines in one deliberate way: an unquoted key may contain spaces (Is Alice happy). Those engines forbid spaces because a key is a program variable; here a key is an opaque label the game resolves, and the trailing sigil already delimits it, so a natural phrase is safe (see D5).

Architecture

The sigil readers isolate the key before validating it, so the unquoted form lives in one shared reader.

flowchart TD
    CS["Code span content"] --> CR["ConditionReader\n(strips ?)"]
    CS --> CW["ChoiceWeightReader\n(strips %)"]
    CR --> RK["QueryKeyReader.Read\n(quoted or unquoted)"]
    CW --> RK
    CS --> GC["GameCallParser.Grammar\n(value read, commands)\nUNCHANGED"]
Type Responsibility
ConditionReader Read a `key?` code span into a Condition.
ChoiceWeightReader Read a `…%` code span into a ChoiceWeight.
QueryKeyReader Turn the text before the sigil into a key, quoted or unquoted.
GameCallParser Recognize a value read and the commands; value reads stay quoted-only.

Key design decisions

D1 — Only a sigil-terminated form may be unquoted

A condition and a weight end in a sigil (?, %); a value read does not. The sigil is a terminator: it marks where the key ends and what operation applies, so the text before it is unambiguously the key. A value read has no such terminator, so an unquoted value read would be ambiguous the moment its key ended in ? or % — is `Is Alice happy?` a value read of the key Is Alice happy? or a condition on Is Alice happy?

Rather than resolve that with a precedence rule the writer must learn, we simply do not offer an unquoted value read. The rule is one sentence: a key may drop its quotes only when a sigil follows it. This also preserves a safety net — a bare `Typo` with no sigil stays not a game call and is reported, instead of silently becoming a value read of a misspelled key.

D2 — Quotes are the escape, detected by shape

Because the sigil is stripped first, the key text stands alone. If it has the shape of a quoted string ("…"), the inner text is the key; otherwise the raw text is the key. So `X?` and `"X"?` both mean the key X, and `"Rainy?"?` means the literal key Rainy?. Quotes never change a key — they only let it carry a character (a trailing ?/%) the unquoted form would read as the operator.

The quoted form has no escape for an inner " — it wraps any run of non-quote characters, matching the existing value-read grammar. A key that must contain a " is therefore written unquoted, where the " is an ordinary character (`He said "hi"?` → key He said "hi"). Text that opens a quote but is not one clean "…" string (`"a"b"?`) is not treated as quoted; it is read as the raw unquoted key "a"b". Since a key is opaque, every non-empty text resolves to some key — there is no "malformed key" to report, only the empty one.

D3 — A weight is a number first, a key second

A weight's text may be a number (50), a key, or empty (an auto weight). The reader tries the number first, so `50%` stays the number 50 rather than a key named 50. A value that parses as a number but is out of range — a negative percentage like `-5%` — is an invalid weight rather than a key named -5. Only text that is not numeric becomes an unquoted key.

D4 — The game-call grammar is untouched

An unquoted key is recognized inside the sigil readers, before the game-call grammar runs. The grammar that distinguishes a value read from a command is unchanged, so a custom command (`JoinClub("Alice")`) cannot be misread as an unquoted key — its parentheses still name it a command. Keeping the value read quoted is what makes this isolation possible: there is no unquoted, sigil-less form for a bare identifier to collide with.

D5 — An unquoted key may contain spaces

An unquoted key is raw text before the sigil, so it may contain spaces, dots, apostrophes, and other punctuation — Is Alice happy, Alice.FavoriteColor, Alice's luck. A key is an opaque label the game resolves, not a program identifier, and the sigil already delimits it, so no identifier grammar is needed. The only characters an unquoted key cannot carry are a trailing ?/% (the operator) and a backtick (which closes the code span); quotes express both.

Markdown interaction

Nothing changes for Markdown. A key still lives inside an inline code span, so an ordinary Markdown preview renders `IsAngry?` as monospace — visibly not speech — exactly as it renders the quoted form. Dropping the quotes only removes two characters from inside the span; it does not change how Markdown tokenizes it.

Diagnostics

No diagnostic code of its own. The unquoted form widens what the two sigil readers accept; the surrounding diagnostics keep their meaning:

  • A value read that is unquoted (`IsAngry`) still reports not a game call (DLG1102) — value reads are quoted-only.
  • An invalid weight is a negative number (`-5%`, DLG1105). Any non-numeric, non-empty weight — `abc%`, `1/2%` — is a key (see D3).
  • An orphan condition (DLG1106) is unaffected — an unquoted condition with nothing to guard is still an orphan.

Error and boundary cases

Input Reads as Why
`IsAngry?` condition, key IsAngry unquoted key + sigil
`Is Alice happy?` condition, key Is Alice happy spaces kept
`"Rainy?"?` condition, key Rainy? quotes escape the inner ?
`He said "hi"?` condition, key He said "hi" a " in a key is literal (unquoted)
`"a"b"?` condition, key "a"b" not one clean "…" → raw key
`?` not a condition empty key
`IsAngry` not a game call (DLG1102) value reads are quoted-only
`Luck%` weight, key Luck unquoted key + sigil
`50%` weight, number 50 number wins over key
`-5%` invalid weight (DLG1105) numeric but out of range
`1/2%` weight, key 1/2 not a number, so a key
`%` auto weight empty weight
`JoinClub("Alice")` custom command grammar untouched

Testability

  • QueryKeyReader — unit tests for a quoted key (inner text), an unquoted key (verbatim, spaces kept), text that is not one clean quoted string (raw key), and empty text (null).
  • ConditionReader — unit tests for the unquoted key, the quoted key, the quoted escape of an inner ?, whitespace trimming, spaces inside a key, and the empty-key rejection.
  • ChoiceWeightReader — unit tests for the unquoted key weight, the number-before-key precedence, the negative-number invalid weight, the auto weight, and the quoted key.
  • End-to-end — UnquotedKeyTranspilationTests transpiles (and desugars, for a jump) a conditional line, choice, random option, and jump written unquoted, plus a key with spaces, asserting each yields the same key its quoted form would.
  • Value read — a bare code span with no sigil still reports not a game call, guarding the quoted-only rule.

Open questions and deferred work

  • Unquoted value reads stay out of scope by D1; revisit only if a terminator is ever added to the value read.