en Tutorial Game Dialogue - chiba233/yume-dsl-token-walker GitHub Wiki
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:
- Define a custom
TNodecommand type (not strings!) - Build a sync interpreter that yields typed commands
- Build an async interpreter that fetches character portraits
- Use
flattenTextfor subtitle / log extraction
Prerequisites: npm install yume-dsl-rich-text yume-dsl-token-walker
$$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.
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[] };// 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"]),
});// 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));
}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;
}
}
}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 logIf 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),
);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.
- Tutorial: Blog Renderer — string output for web
- Tutorial: Editor Lint + Auto-fix — validate DSL source
- Async Interpret — full async API reference