en Tutorial Game Dialogue - chiba233/yume-dsl-token-walker GitHub Wiki

Tutorial: Game Dialogue Engine

Home | Interpret | Async Interpret

Build a visual novel dialogue system where DSL tags become typed commands — not HTML strings. A typewriter engine consumes these commands to animate text character by character, with shake effects, color changes, and pauses.

What you'll learn:

  1. Define a custom TNode command type (not strings!)
  2. Build a sync interpreter that yields typed commands
  3. Build an async interpreter that fetches character portraits
  4. Use flattenText for subtitle / log extraction

Prerequisites: npm install yume-dsl-rich-text yume-dsl-token-walker


The DSL your writers will use

$$speaker(Alice)*
Hello! $$shake(This is important!)$$
$$wait(500)$$
$$color(#ff6b6b | And this is red.)$$
*end$$

Writers type readable markup. Your engine turns it into frame-by-frame commands.


Step 1: Define command types

Your TNode is not string — it's a discriminated union of typed commands:

// commands.ts
export type DialogueCommand =
    | { kind: "text"; value: string }
    | { kind: "shake"; children: DialogueCommand[] }
    | { kind: "color"; hex: string; children: DialogueCommand[] }
    | { kind: "wait"; ms: number }
    | { kind: "speaker"; name: string; children: DialogueCommand[] };

Step 2: Set up the parser

// parser.ts
import {
    createParser,
    createSimpleInlineHandlers,
    createSimpleBlockHandlers,
    createPipeHandlers,
    declareMultilineTags,
} from "yume-dsl-rich-text";

export const parser = createParser({
    handlers: {
        ...createSimpleInlineHandlers(["shake"]),
        ...createSimpleBlockHandlers(["speaker"]),
        ...createPipeHandlers({
            color: {
                inline: (args, ctx) => ({
                    type: "color",
                    hex: args.text(0, "#ffffff"),
                    value: args.materializedTailTokens(1),
                }),
            },
            wait: {
                inline: (args, ctx) => ({
                    type: "wait",
                    ms: parseInt(args.text(0, "0"), 10) || 0,
                    value: "",
                }),
            },
        }),
    },
    blockTags: declareMultilineTags(["speaker"]),
});

Step 3: Build the interpreter

// interpret.ts
import type { DialogueCommand } from "./commands";
import { createRuleset, fromHandlerMap, interpretTokens, collectNodes } from "yume-dsl-token-walker";
import { parser } from "./parser";

const ruleset = createRuleset<DialogueCommand, void>({
    createText: (text) => ({ kind: "text", value: text }),
    interpret: fromHandlerMap({
        shake: (token, h) => ({
            type: "nodes",
            nodes: [{ kind: "shake", children: Array.from(h.interpretChildren(token.value)) }],
        }),
        color: (token, h) => ({
            type: "nodes",
            nodes: [{
                kind: "color",
                hex: typeof token.hex === "string" ? token.hex : "#ffffff",
                children: Array.from(h.interpretChildren(token.value)),
            }],
        }),
        wait: (token) => ({
            type: "nodes",
            nodes: [{
                kind: "wait",
                ms: typeof token.ms === "number" ? token.ms : 0,
            }],
        }),
        speaker: (token, h) => ({
            type: "nodes",
            nodes: [{
                kind: "speaker",
                name: typeof token.arg === "string" ? token.arg : "???",
                children: Array.from(h.interpretChildren(token.value)),
            }],
        }),
    }),
    onUnhandled: "flatten",
});

export function parseDialogue(dslSource: string): DialogueCommand[] {
    return collectNodes(interpretTokens(parser.parse(dslSource), ruleset, undefined));
}

Step 4: The typewriter engine

The typewriter consumes DialogueCommand[] and animates them:

// typewriter.ts
import type { DialogueCommand } from "./commands";

async function playCommands(commands: DialogueCommand[], context: {
    typeChar: (char: string, style?: { shake?: boolean; color?: string }) => void;
    showSpeaker: (name: string) => void;
    delay: (ms: number) => Promise<void>;
}) {
    for (const cmd of commands) {
        switch (cmd.kind) {
            case "text":
                for (const char of cmd.value) {
                    context.typeChar(char);
                    await context.delay(30); // 30ms per character
                }
                break;
            case "shake":
                // Recursively play children with shake style
                await playCommandsWithStyle(cmd.children, context, { shake: true });
                break;
            case "color":
                await playCommandsWithStyle(cmd.children, context, { color: cmd.hex });
                break;
            case "wait":
                await context.delay(cmd.ms);
                break;
            case "speaker":
                context.showSpeaker(cmd.name);
                await playCommands(cmd.children, context);
                break;
        }
    }
}

Step 5: Plain text for subtitles

import { flattenText } from "yume-dsl-token-walker";

const tokens = parser.parse("$$speaker(Alice)* Hello $$shake(world)$$! *end$$");
const subtitle = flattenText(tokens);
// → " Hello world! " — for subtitle track or dialogue log

Step 6 (optional): Async interpreter for character portraits

If your speaker handler needs to fetch a portrait from a CDN:

import type { AsyncInterpretRuleset } from "yume-dsl-token-walker";
import { interpretTokensAsync, collectNodesAsync } from "yume-dsl-token-walker";

const asyncRuleset: AsyncInterpretRuleset<DialogueCommand, void> = {
    createText: (text) => ({ kind: "text", value: text }),
    interpret: async (token, helpers) => {
        if (token.type === "speaker") {
            const name = typeof token.arg === "string" ? token.arg : "???";
            // Could fetch portrait URL here:
            // const portrait = await fetch(`/api/portraits/${name}`);
            const children: DialogueCommand[] = [];
            for await (const child of helpers.interpretChildren(token.value)) {
                children.push(child);
            }
            return {
                type: "nodes",
                nodes: [{ kind: "speaker", name, children }],
            };
        }
        // ... other handlers
        return { type: "unhandled" };
    },
};

const commands = await collectNodesAsync(
    interpretTokensAsync(parser.parse(dslSource), asyncRuleset, undefined),
);

What you've built

Writer's DSL text
    │
    ▼
parser.parse() → TextToken[]
    │
    ▼
interpretTokens(tokens, ruleset) → DialogueCommand[]
    │
    ▼
typewriter engine → animated output

Key insight: TNode is whatever you want. Strings for HTML, commands for games, VNodes for React — the interpreter doesn't care.


Next steps

⚠️ **GitHub.com Fallback** ⚠️