Skills に戻る
gamedev-skills/awesome-gamedev-agent-skills実行前に内容を確認

SKILL DETAIL

dialogue-systems

gamedev-skills/awesome-gamedev-agent-skills/dialogue-systems

Build branching dialogue and narrative — a node/choice graph with conditions, variables, and localization hooks — and choose between authoring tools Ink and Yarn Spinner or a custom data-driven runner. Engine-neutral. Use when the user mentions dialogue system, branching dialogue, conversation tree, choices, Ink (.ink), Yarn Spinner (.yarn), or NPC dialogue.

インストール · 305出典を見る

Installation

npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill dialogue-systems

スキルファイル

SKILL.md

最終同期 · 2026/10/22

references/ink-and-yarn.md›
# Ink and Yarn Spinner — syntax cheat sheet

Both are mature, open-source narrative tools. Pick **Ink** for prose-first,
heavily branching writing; pick **Yarn Spinner** for node-graph dialogue with
explicit engine commands. This sheet covers the constructs you reach for most;
consult the official docs for the full language.

## Side-by-side

| Concept | Ink (inkle) | Yarn Spinner 3.x |
|---|---|---|
| Unit of content | **knot** `=== name ===` (sub-units = stitches `= name`) | **node** with `title:` header, body between `---` and `===` |
| Plain line | bare text | bare text, optional `Speaker:` prefix |
| Choice / option | `*` once-only, `+` sticky | `->` option, with indented body |
| Move flow | divert `-> name`, end `-> END` / `-> DONE` | `<<jump NodeName>>` |
| Declare variable | `VAR gold = 0` (global), `~ temp x = 0` | `<<declare $gold = 0>>` |
| Set variable | `~ gold = gold - 5` | `<<set $gold = $gold - 5>>` |
| Show variable | `{gold}` | `{$gold}` |
| Condition on choice | `* {gold >= 50} [Bribe] ...` | `-> Bribe <<if $gold >= 50>>` |
| Conditional text | `{flag: yes text\|no text}` | `<<if $flag>>...<<else>>...<<endif>>` |
| Constants | `CONST MAX = 3` | (use a declared variable) |
| Comments | `// line` and `/* block */` | `// line` |

## Ink essentials

```ink
INCLUDE characters.ink              // split content across files (top of file)
VAR met_guard = false               // global variable
CONST GATE_COST = 50

-> tavern                           // content outside a knot runs first; divert in

=== tavern ===
The tavern is warm and loud.
* [Talk to the guard] -> guard
* [Leave] -> END

=== guard ===
~ met_guard = true                  // '~' marks a logic line (assignment / call)
{ met_guard: "You again." | "Who are you?" }   // conditional text: seen-before?
+ {gold >= GATE_COST} [Pay {GATE_COST} gold]    // sticky choice, gated, interpolated
    ~ gold = gold - GATE_COST
    -> END
+ [Never mind] -> tavern            // sticky so the menu can loop
```

Useful Ink behaviors:

- **Square brackets in a choice** split what the player sees from what prints:
  `* "Yes[."]," I said.` shows `Yes.` as the option but prints `Yes," I said.`
- **Gathers** (`-`) rejoin branches without naming a knot — the core of "weave".
- **Read counts**: any knot/stitch name used in a condition is the number of
  times it has been visited (`{seen_clue > 2}`), so flags are often free.
- **Alternatives** vary text on revisits: `{a|b|c}` sequence, `{&a|b}` cycle,
  `{!a|b}` once-only, `{~a|b}` shuffle.
- **Randomness**: `RANDOM(1, 6)` (inclusive); `SEED_RANDOM(n)` for repeatable tests.
- **Functions**: `=== function name(args) ===` with `~ return value`.

## Yarn Spinner essentials

```yarn
title: Tavern
---
<<declare $gold = 60>>
<<declare $met_guard = false>>
The tavern is warm and loud.
-> Talk to the guard
    <<jump Guard>>
-> Leave
    Narrator: You step back into the night.
===

title: Guard
---
<<set $met_guard to true>>
<<if $met_guard>>
    Guard: You again.
<<else>>
    Guard: Who are you?
<<endif>>
-> Pay 50 gold <<if $gold >= 50>>
    <<set $gold = $gold - 50>>
    Guard: ...go on through. You have {$gold} left.
-> Never mind
    <<jump Tavern>>
===
```

Useful Yarn behaviors:

- **Variables** start with `$`, hold number/string/bool, and must keep their
  type. `<<declare>>` them up front (often in a setup node).
- **Operators** have word and symbol forms: `is`/`==`, `and`/`&&`, `or`/`||`,
  `not`/`!`, plus `<`, `>`, `<=`, `>=`.
- **Commands** (`<<...>>`) that aren't built in are dispatched to your game code,
  e.g. `<<playSound door_open>>` calls a registered command handler.
- **Functions** (`visited("Node")`, custom registered functions) return values
  usable in `<<if>>` and `{ }` interpolation.

## Integration notes

- **Ink**: compile `.ink` to JSON, then step the runtime: it yields lines and
  choice lists; you call `Continue()` / `ChooseChoiceIndex()`. Bindings exist for
  Unity (official), Godot (community), and web (inkjs).
- **Yarn Spinner**: a `DialogueRunner` steps `.yarn` nodes, raising line, options,
  and command events your views handle. First-class Unity support; Godot and
  other runtimes exist.
- **Localization**: both support extracting line IDs/tags to string tables. Tag
  lines and ship per-locale tables rather than translating the script in place.
- **Variable bridging**: expose game state to the script (gold, flags, quest
  stage) via variable storage / external functions so dialogue reacts to play,
  and persist that state through `save-systems`.

Originality note: the snippets above are minimal originals written to illustrate
each construct, verified against the official Ink and Yarn Spinner documentation.
references/runner.md›
# A custom dialogue runner

When Ink/Yarn are more than you need, a small data-driven runner is enough for
lines, choices, conditions, and variable writes. Build a **graph interpreter**,
not a language. This reference gives a complete, engine-neutral design.

## Graph schema

```json
{
  "start": "node_id",
  "vars":  { "gold": 0, "met_guard": false },
  "nodes": {
    "<id>": {
      "speaker": "Guard",              // optional
      "line":    "DLG_KEY",            // localization key (or raw text in a pinch)
      "set":     { "var": "expr" },    // optional: assignments applied on entry/transition
      "choices": [                       // optional: branch point
        { "text": "OPT_KEY", "to": "<id>", "if": "expr", "set": { } }
      ],
      "next":    "<id>",               // optional: linear auto-advance
      "end":     true                  // optional: terminate the conversation
    }
  }
}
```

Exactly one of `choices`, `next`, or `end` should determine where a node goes.

## Variable store and expression evaluation

Keep variables in a flat dict. Evaluate conditions/assignments with a **tiny,
sandboxed** expression evaluator — never `eval()` untrusted strings in a way that
exposes the host language.

```python
import ast, operator as op

_BIN = {ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv,
        ast.Mod: op.mod}
_CMP = {ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le,
        ast.Gt: op.gt, ast.GtE: op.ge}
_BOOL = {ast.And: all, ast.Or: any}

def evaluate(expr, vars):
    """Safely evaluate a boolean/arithmetic expression over the variable store."""
    if expr is None or expr == "":
        return True
    def ev(node):
        if isinstance(node, ast.Expression):  return ev(node.body)
        if isinstance(node, ast.Constant):    return node.value
        if isinstance(node, ast.Name):        return vars.get(node.id)
        if isinstance(node, ast.BoolOp):      return _BOOL[type(node.op)](ev(v) for v in node.values)
        if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
            return not ev(node.operand)
        if isinstance(node, ast.BinOp):       return _BIN[type(node.op)](ev(node.left), ev(node.right))
        if isinstance(node, ast.Compare):
            left = ev(node.left); res = True
            for o, c in zip(node.ops, node.comparators):
                right = ev(c); res = res and _CMP[type(o)](left, right); left = right
            return res
        raise ValueError("unsupported expression")
    return ev(ast.parse(expr, mode="eval"))
```

This whitelists only names, literals, arithmetic, comparison, and boolean ops —
it cannot call functions or touch the host environment. (Engines without a safe
parser should ship a hand-written tokenizer instead of exposing a real `eval`.)

## The runner state machine

```python
class DialogueRunner:
    def __init__(self, graph, ui, localize):
        self.graph, self.ui, self.localize = graph, ui, localize
        self.vars = dict(graph.get("vars", {}))
        self.current = None

    def start(self):
        self._goto(self.graph["start"])

    def _apply_set(self, sets):
        # Evaluate each RHS against the CURRENT vars, then commit (snapshot first
        # so 'a: b' and 'b: a' in one block don't see each other's new values).
        updates = {k: evaluate(v, self.vars) if isinstance(v, str) else v
                   for k, v in sets.items()}
        self.vars.update(updates)

    def _goto(self, node_id):
        node = self.graph["nodes"][node_id]
        self.current = node
        self._apply_set(node.get("set", {}))
        if node.get("end"):
            self.ui.close(); return
        if "line" in node:
            self.ui.show_line(node.get("speaker", ""), self.localize(node["line"]))
        if "choices" in node:
            shown = [c for c in node["choices"] if evaluate(c.get("if"), self.vars)]
            self.ui.show_choices(shown)            # UI calls choose() with one of these
        elif "next" in node:
            self.ui.show_continue(lambda: self._goto(node["next"]))

    def choose(self, choice):
        self._apply_set(choice.get("set", {}))     # write on the transition (revisit-safe)
        self._goto(choice["to"])
```

## Localization lookup

```python
def make_localizer(tables, locale, fallback="en"):
    table = tables.get(locale, {})
    base  = tables.get(fallback, {})
    def localize(key):
        return table.get(key, base.get(key, f"<MISSING:{key}>"))  # never crash on a gap
    return localize
```

Ship one table per locale keyed by line ID. A missing key returns a visible
placeholder rather than failing — easy to spot in QA.

## Validation pass

Before shipping a dialogue file, statically check:

- every `to`/`next` target exists in `nodes`;
- every node has a terminator (`choices`, `next`, or `end`);
- no choice references a variable never declared in `vars`;
- (optional) reachability: flood from `start` and warn on orphan nodes.

These catch the dead-ends and typos that otherwise strand the player mid-scene.
SKILL.md›
---
name: dialogue-systems
description: >
  Build branching dialogue and narrative — a node/choice graph with conditions,
  variables, and localization hooks — and choose between authoring tools Ink and
  Yarn Spinner or a custom data-driven runner. Engine-neutral. Use when the user
  mentions dialogue system, branching dialogue, conversation tree, choices,
  Ink (.ink), Yarn Spinner (.yarn), or NPC dialogue.
---

# Dialogue systems

Model conversations as a **graph**: nodes hold lines, choices branch the flow,
conditions gate options, and variables remember what the player did. The first
real decision is *build vs. buy* — adopt a proven authoring tool (**Ink** or
**Yarn Spinner**) or write a small data-driven runner. This skill owns both Ink
and Yarn; the `visual-novel` and `rpg` genres consume it.

## When to use

- Use to design branching conversations, choice menus, or narrative state
  (flags, relationship values) that affect later dialogue.
- Use to decide between Ink, Yarn Spinner, and a custom JSON/resource format.
- Use to wire a dialogue script into your game loop (advance line, present
  choices, run commands, resolve variables).

**When *not* to use:** for engine UI (text boxes, portraits, choice buttons), use
`godot-ui-control` or the engine's UI skill. For persisting narrative variables
across sessions, use `save-systems`. For data-as-resources in Godot/Unity, see
`godot-resources` / `unity-scriptableobjects`.

## Core workflow

1. **Choose the authoring approach.**
   - **Ink** — prose-first, writer-friendly, weave/gather flow; great for
     dialogue-heavy or CYOA narrative. Integrate via ink runtime / inkle plugins.
   - **Yarn Spinner** — node-based, explicit `<<commands>>`, strong for
     game-driven dialogue with lots of engine hooks.
   - **Custom runner** — a JSON/resource graph + a small interpreter when you need
     full control or minimal dependencies. Don't build a *language*; build a graph.
2. **Define the node contract.** A node yields one of: a line (speaker + text),
   a set of choices, a command/side-effect, or an end/jump. The runner advances
   through nodes and hands lines/choices to the UI.
3. **Separate variables from flow.** Keep a variable store (booleans, numbers,
   strings) the dialogue reads/writes; gate choices with conditions over it.
4. **Localize from the start.** Author with **line IDs**, not raw strings, so the
   displayed text comes from a string table keyed by locale.
5. **Drive it from the game loop.** The runner is a state machine: `current` node
   → emit content → wait for input (continue or choice) → advance.
6. **Verify by walking branches.** Exercise each choice path; confirm conditions,
   variable writes, and that every branch reaches an end or a valid jump.

## Patterns

### 1. Engine-neutral dialogue graph (data, not code)

```json
{
  "start": "guard_intro",
  "nodes": {
    "guard_intro": {
      "speaker": "Guard", "line": "DLG_GUARD_001",
      "choices": [
        { "text": "DLG_OPT_BRIBE", "to": "bribe", "if": "gold >= 50" },
        { "text": "DLG_OPT_LEAVE", "to": "end" }
      ]
    },
    "bribe": {
      "speaker": "Guard", "line": "DLG_GUARD_BRIBED",
      "set": { "gate_open": true, "gold": "gold - 50" },
      "next": "end"
    },
    "end": { "end": true }
  }
}
```

`line`/`text` are **string-table IDs** (localization), not literal text. `if`
gates a choice; `set` mutates the variable store. The full interpreter that walks
this graph is in `references/runner.md`.

### 2. Runner step (a state machine over the graph)

```gdscript
# The runner holds the current node and a variable store; the UI calls advance().
func present(node):
    if node.has("line"):
        ui.show_line(node.speaker, localize(node.line))
    if node.has("choices"):
        var shown = node.choices.filter(func(c): return eval_cond(c.get("if", "")))
        ui.show_choices(shown)            # only choices whose condition passes

func choose(choice):                       # called when the player clicks a choice
    apply_set(choice.get("set", {}))       # write variables
    goto(choice.to)

func goto(id):
    current = graph.nodes[id]
    apply_set(current.get("set", {}))
    if current.get("end", false): ui.close(); return
    present(current)
    if current.has("next") and not current.has("choices"):
        goto(current.next)                 # auto-advance linear nodes
```

### 3. Ink — branching with knots, choices, and variables (inkle)

```ink
// Ink: '*' = once-only choice, '+' = sticky. [bracketed] text shows only in the
// choice, not the printed result. '->' diverts; '-> END' stops the flow.
VAR gold = 60

=== guard_intro ===
The guard blocks the gate.
* {gold >= 50} [Offer 50 gold]   "Here, take it."
    ~ gold = gold - 50
    The guard pockets it and steps aside. -> END
* [Leave]   You turn back. -> END
```

Ink tracks how often each knot was seen, so `{visited_knot}` is a built-in
condition. Variables are global (`VAR`) or temporary (`~ temp`).

### 4. Yarn Spinner — nodes, options, and commands (Yarn Spinner 3.x)

```yarn
title: GuardIntro
---
<<declare $gold = 60>>
<<declare $gate_open = false>>
Guard: You can't pass.
-> Offer 50 gold <<if $gold >= 50>>
    <<set $gold = $gold - 50>>
    Guard: ...fine. Go on through.
    <<set $gate_open to true>>
-> Leave
    Guard: Good choice.
===
```

Yarn lines may start with `Speaker:`; options use `->`; `<<set>>`/`<<declare>>`
manage `$variables`; `<<if>>` gates an option; `<<jump NodeName>>` moves between
nodes. Interpolate values in text with `{$gold}`. Declare every variable before first
use: an undeclared one still compiles (Yarn infers its type and a zero/false/empty
start value), but that hides typos. These constructs are unchanged from Yarn Spinner 2.x.

## Pitfalls

- **Hardcoding display strings** instead of line IDs makes localization a
  rewrite. Author against a string table from day one.
- **Inventing a scripting language** for a simple branching tree. If you only
  need lines + choices + flags, a JSON/resource graph plus a 50-line runner beats
  a parser you must maintain. Use Ink/Yarn when writers need real flow control.
- **Variables coupled to the UI**: store narrative state separately so the same
  dialogue works in cutscenes, menus, and tests. Persist it via `save-systems`.
- **Unreachable or dead-end nodes**: a node with no `next`, choices, or end
  silently stalls. Validate that every node terminates or branches.
- **Mutating state in a line node the player can revisit** double-applies (`gold`
  drained twice). Apply `set` on the transition, or guard with a seen-flag.
- **Mixing Ink's `*` (once-only) and `+` (sticky)** by accident: looped menus
  need sticky `+` choices or the options vanish after one use.

## References

- `references/ink-and-yarn.md` — side-by-side syntax cheat sheet (choices,
  diverts/jumps, variables, conditions, includes) and integration notes.
- `references/runner.md` — a complete custom dialogue runner: graph schema,
  condition/expression evaluation, variable store, and localization lookup.

## Related skills

- `save-systems` — persist narrative variables and seen-flags.
- `godot-resources`, `unity-scriptableobjects` — store dialogue as engine data.
- `godot-ui-control` — render text boxes, portraits, and choice buttons.
- `visual-novel`, `rpg` — genres that compose this skill.