BBCode Rendering
Note
Status: proposed. A library survey and a proposed ISpeechFormatter seam for rendering a
playbook line's speech fragments as BBCode for Godot, as terminal markup, and as HTML. None of it
is built; the plain-text reading is SpeechText.Of
(Speech as Plain Text).
A compiled playbook carries each line's speech as a list of
fragments — plain text, styled runs, links, images, and game constructs — because every host renders
it differently. The primary consumer is Godot, whose RichTextLabel renders BBCode natively; a
terminal or web preview would want styled speech too. This note surveys how to serve all three from
one seam.
Table of contents
- Goal and scope
- The key framing: two opposite directions
- What the playbook gives us
- Emit target: Godot BBCode
- Rendering surfaces and their libraries
- Tradeoffs discovered
- Evaluation and recommendation
- Implementation guidance
- Open questions
Goal and scope
Give the project one way to turn a line's inline fragments into displayed text on three surfaces — Godot (BBCode), the terminal preview, and the web preview — without duplicating the styling logic per surface, and without pulling a rendering dependency into the game-facing libraries.
Out of scope: animation and typewriter effects, per-game theming, and the runtime that walks the playbook. This note is about formatting one line's fragments, not about playing a script.
The key framing: two opposite directions
"Support BBCode" bundles two operations that are inverses. Separating them avoids over-tooling, because they need very different amounts of code.
| Direction | What it is | Where it is needed | Tooling weight |
|---|---|---|---|
| Emit (fragments → BBCode) | Inline fragments → a BBCode string | Feeding Godot RichTextLabel — the real consumer |
None — a small visitor |
| Render (BBCode → display) | A BBCode string → HTML / terminal styling | The web and terminal previews | A parser library helps |
The important consequence: for the project's own previews we already hold the fragments, so a preview can render the fragments directly to terminal markup or HTML — it does not have to round-trip through BBCode. A BBCode parser is only needed if a preview should consume the exact BBCode string Godot receives, as a fidelity check on the emitter. That single decision determines whether any parsing dependency is taken at all.
flowchart TB
AST["Playbook speech<br/>(SpeechFragment list)"]
AST --> BB["BBCode formatter<br/>→ Godot RichTextLabel"]
AST --> SP["Spectre formatter<br/>→ terminal"]
AST --> HT["HTML render<br/>→ web preview"]
BB -. "optional fidelity check" .-> PARSE["BBCode parser<br/>(bbob / CodeKicker)"]
PARSE -. re-render .-> HT
What the playbook gives us
The fragments a formatter must handle, from DialogueDown.Playbook:
| Fragment | Carries | Notes |
|---|---|---|
TextFragment |
literal words | escape target-specific metacharacters |
StyledTextFragment |
a SpeechStyle (Italic, Bold, Strikethrough) + nested children |
styles nest, so bold-inside-italic is two wrappers |
LineBreakFragment |
— | a soft break hint |
LinkFragment |
target + label fragments | |
ImageFragment |
source + alt fragments | |
QueryFragment, DefaultCommandFragment, CustomCommandFragment, TagFragment |
game constructs | no direct visual equivalent; map to custom tags or resolve before rendering |
Styling records only that text is italic, bold, or struck through; how it renders is a downstream concern (Markdown Front-End, the script language spec). This note proposes that seam.
Emit target: Godot BBCode
Godot's RichTextLabel
renders BBCode when bbcode_enabled is set. The fragment-to-tag mapping is
direct, so emitting is a plain visitor with no library:
| Fragment | BBCode | Note |
|---|---|---|
TextFragment |
the literal | escape [ as [lb] |
StyledTextFragment Italic / Bold / Strikethrough |
[i]…[/i] / [b]…[/b] / [s]…[/s] |
recurse into children |
LinkFragment |
[url=target]label[/url] |
|
ImageFragment |
[img]source[/img] |
|
LineBreakFragment |
newline | |
| Query / command / tag fragments | custom BBCode tags | Godot supports custom tags and RichTextEffects |
Godot's custom-tag and RichTextEffect support means the game-specific
fragments have a natural home: emit [cmd=name]…[/cmd]-style tags and let the
game's presentation layer interpret them.
Rendering surfaces and their libraries
Libraries only matter on the render side (BBCode → display), and only if a preview consumes BBCode rather than the fragments. The survey below is a snapshot of the ecosystem at the time of writing; re-check maintenance before adopting.
Web (JavaScript/TypeScript)
Recommendation: @bbob/* (bbob). It is
the only actively maintained, TypeScript-native, framework-agnostic option, and
its AST + plugin architecture fits custom tags (jump, call) and can even
re-serialize back to BBCode.
| Library | Maintained | License | Size (gz) | Custom tags | TS | AST |
|---|---|---|---|---|---|---|
@bbob/html + preset-html5 |
Active | MIT | ~5.5 KB | .extend() |
Yes | Yes |
js-bbcode-parser |
Quiet | MIT | ~0.7 KB | regex only | No | No (XSS-unsafe) |
bbcode-to-react |
Stale (2018) | MIT | ~4.3 KB | class-based | No | React-only |
xbbcode |
Dead (2016) | MIT | ~1 KB | template | No | No |
Notes: bbob is lazy-loadable, which suits the preview's code-splitting. One
caveat — its stock url tag does not strip a javascript: scheme, so override
that tag or pair the output with a sanitizer.
Terminal (.NET)
The CLI already uses Spectre.Console (see
Command-Line Interface). Spectre has its own
markup ([bold]…[/]), not BBCode, and no OSS bridge between the two exists.
Two clean paths:
- Emit only (fragments are the source): write a Spectre formatter — the same visitor pattern as the BBCode one with a different string table. No new dependency.
- Parse BBCode (preview consumes the emitted string):
CodeKicker.BBCode.Coreis the only viable library — MIT,net8.0, a real AST with aSyntaxTreeVisitor, zero dependencies — then a small visitor maps its nodes to Spectre markup.
| Library | Maintained | License | net8.0 |
AST + visitor |
|---|---|---|---|---|
| Spectre.Console (incumbent) | Active | MIT | Yes | render target |
CodeKicker.BBCode.Core (only if parsing) |
Active | MIT | Yes | Yes |
CodeKicker.BBCode |
Dead (2017) | MIT | No (net35) | Yes |
BBCodeParser |
Dead (2018) | none listed | — | — |
CodeKicker.BBCode.Core risk: a single maintainer with low community signal —
mitigated by its MIT license and small, vendorable source.
Tradeoffs discovered
- Emit is the real work; parsing is optional. The valuable, reusable code is the fragment visitor. A parser is a convenience for one specific preview mode (BBCode fidelity), not a core need.
- Game-facing libraries stay engine-agnostic. A BBCode formatter is pure
string building and can sit beside
SpeechTextinDialogueDown.Playbook; a Spectre formatter depends on Spectre and must live in the CLI. - Markup dialects differ in closing syntax. BBCode closes by name
(
[/b]); Spectre closes generically ([/]) and combines styles in one tag ([bold red]). A shared emitter cannot target both verbatim — hence one small formatter per dialect over a shared traversal. - Escaping is per-target and mandatory. Literal
[becomes[lb]for Godot and[[for Spectre. Getting this wrong silently corrupts output, so it is the first thing to test. - Security lives on the render side. BBCode→HTML must guard against
javascript:URLs and unescaped text; emitting BBCode has no such exposure.
Evaluation and recommendation
- Build the emit seam first. An
ISpeechFormatterinDialogueDown.Playbookwith aBBCodeSpeechFormattercovers the primary Godot use case and needs no dependency. - Add a Spectre formatter in the preview/CLI project for the terminal preview — again a small visitor, no new dependency.
- Render the web preview from the fragments (the playbook JSON) directly.
Reach for
@bbob/*only if the preview should consume the emitted BBCode string; reach forCodeKicker.BBCode.Coreonly for the analogous .NET BBCode-parsing case. Both are MIT and AST-based. - Keep Spectre.Console as the terminal renderer as-is.
Net: two or three tiny formatters over a shared traversal, and zero required third-party libraries — a parser enters only if BBCode fidelity-checking is later wanted.
Implementation guidance
Enough to reproduce each piece. All code below is proposed, not implemented.
The seam: ISpeechFormatter
One interface beside SpeechText turns a line's fragments into a string for a target.
It is the single place styling decisions live.
// proposed — in DialogueDown.Playbook
public interface ISpeechFormatter
{
string Format(ImmutableArray<SpeechFragment> speech);
}
Each surface provides an implementation. A shared abstract visitor can walk the
fragment tree and call small abstract hooks (OpenStyle, CloseStyle,
EscapeText, …) that each formatter fills in, so the traversal is written once.
Reproduce a BBCode formatter (Godot)
A visitor that appends tags. No dependency.
// proposed
public sealed class BBCodeSpeechFormatter : ISpeechFormatter
{
public string Format(ImmutableArray<SpeechFragment> speech)
{
var sb = new StringBuilder();
Write(sb, speech);
return sb.ToString();
}
private static void Write(StringBuilder sb, ImmutableArray<SpeechFragment> fragments)
{
foreach (var fragment in fragments)
{
switch (fragment)
{
case TextFragment t:
sb.Append(t.Text.Replace("[", "[lb]"));
break;
case StyledTextFragment s:
var tag = s.Style switch
{
SpeechStyle.Italic => "i",
SpeechStyle.Bold => "b",
SpeechStyle.Strikethrough => "s",
_ => null,
};
if (tag is not null) sb.Append('[').Append(tag).Append(']');
Write(sb, s.Children);
if (tag is not null) sb.Append("[/").Append(tag).Append(']');
break;
case LineBreakFragment:
sb.Append('\n');
break;
// Link, image, query, command, and tag fragments → their BBCode / custom tags
}
}
}
}
Reproduce a Spectre formatter (terminal)
The same traversal, a different string table, and Spectre's generic close [/].
Escape literal [ with Spectre's Markup.Escape (or [[). Lives in the
CLI so the game-facing libraries take no Spectre dependency.
| Fragment | Spectre markup |
|---|---|
TextFragment |
Markup.Escape(text) |
StyledTextFragment Italic / Bold / Strikethrough |
[italic]…[/] / [bold]…[/] / [strikethrough]…[/] |
LineBreakFragment |
newline |
Reproduce an HTML render (web)
The preview already has the fragments (or their JSON). Render them straight to
elements — <em>, <strong>, <del>, <a> — escaping text. Only if the
preview must consume the emitted BBCode string, use bbob with a custom preset:
// proposed — only if consuming BBCode, not the fragments
import bbobHTML from '@bbob/html'
import presetHTML5 from '@bbob/preset-html5'
import { TagNode } from '@bbob/plugin-helper'
const ddPreset = presetHTML5.extend((tags) => ({
...tags,
jump: (n) => TagNode.create('a', { class: 'dd-jump', 'data-scene': n.attrs?.jump ?? '' }, n.content),
url: (n, { render }) => {
const href = n.attrs ? String(Object.values(n.attrs)[0]) : render(n.content ?? [])
return TagNode.create('a', { href: /^javascript:/i.test(href) ? '#' : href }, n.content)
},
}))
const html = bbobHTML('[b]Hi[/b] [jump=scene_1]Go[/jump]', ddPreset())
Optional: parse BBCode back
Only for a fidelity check that a preview shows exactly what Godot would. In .NET,
CodeKicker.BBCode.Core parses to a SequenceNode; subclass SyntaxTreeVisitor
and emit Spectre markup or HTML. In the web client, bbob's parser yields the same
AST it renders from. Skip this entirely if previews render from the fragments.
Open questions
- Scope of the first formatter. Ship BBCode-for-Godot alone, or land the Spectre and HTML formatters together so the previews and the engine share the seam from day one?
- Where the seam lives.
ISpeechFormatterbesideSpeechTextsuits BBCode and HTML; the Spectre formatter must sit in the CLI. Confirm the project boundary (library interface, edge implementations) matches the architecture rules. - Custom-tag vocabulary. Which BBCode tags represent query, command, and tag fragments, and are they resolved before rendering or passed through for the game to interpret?
- Fidelity check. Is round-tripping through a BBCode parser worth a dependency, or is rendering previews from the fragments sufficient?