FolioTier 2
text-tags
Inline formatting, motion, appearance, and pacing grammar that lives inside `say` and `narrate` string content.
Text tags are inline markup you drop directly into a say or narrate string to shape how that specific line reads on screen, without touching the surrounding script structure. Bold, italics, color, pauses, and speed changes all live here as bracketed tags around the words they affect, so you can bend emphasis, timing, and mood word by word inside a single line of dialogue.
Reach for them whenever a line needs to perform rather than just inform - a character stressing one word, a name that should always render in a house color, a dramatic beat before a reveal. They're the difference between a line that's technically correct and one that sounds like the character speaking it.
Tags must be closed - an unclosed {b} or {speed=} will bleed formatting into every line after it, so treat opening and closing tags as a pair even when it's tempting to skip the closer for a quick test. Pause and speed tags affect the typewriter reveal timing, not voiceover or audio sync, so don't rely on them to line up with a sound cue. Because these tags are parsed out of the string, an author can't easily search dialogue text for a literal curly brace without escaping it, which matters if a character is ever meant to say something like a stat block or code snippet aloud. Keep nesting shallow - stacking several tags around the same word works, but three or four deep gets hard to proofread and easy to mismatch.
Reference page. Text tags are inline grammar inside say / narrate
string content, not a standalone verb. The parser shipped in Phase 3
Step 4 - see
apps/web/src/lib/runtime/effects/text/parser.ts.
The per-glyph renderer ({vibrate} / {wave}) shipped in Step 5; the
static-tag styling ({b} / {i} / {color} / {size}) shipped in
Step 6; and the timing and pacing tags ({glitch}, {pause},
{speed}, {instant}) drive a per-character reveal schedule at
effects/text/reveal-schedule.ts.
This page documents the closed grammar so the parser and the importer
share one target.
How tags work
A tag is delimited by { and }. Static tags wrap a span of text
with a matching {/tag}; animated and special tags have the same
shape. The grammar is closed - only the tags listed here are
recognized. Unknown tags are a parse error at import time and an
inline editor warning in the studio, both with the documented
"unknown text tag" issue code; the migration report surfaces them
under the same code.
say "Emma": "I {b}can't{/b} believe you {i}did{/i} that."
Nesting is allowed; mismatched closers reject at parse time. Tags
that take a value use = ({color=#ff0066}…{/color}); tags without
a value use the bare form ({b}…{/b}).
One tag is void: {pause=0.6} is a marker dropped into the flow
of a line, not a wrapper, so it never takes a {/pause}. Writing one
is a reported issue, not a silent nest.
Inserting tags from the Forge timeline
dialogue and narrate event cards in the timeline show a tag
toolbar above the text input - that's where the runtime parses
inline tags today. The buttons wrap the current selection with the
matching open / close pair (or insert an empty pair at the caret).
B / I are one-click and every other style folds into one Style
tray; color ▾ opens a swatch palette (including an Accent (theme)
token that follows the theme) plus a #rrggbb field; size ▾ opens
absolute point presets (12, 16, base, 30, 38, 48 pt).
Implementation:
rich-text-toolbar.tsx.
The toolbar is intentionally not shown on choice prompts, choice
option labels, or scene titles - PlayerChoiceOverlay renders those
fields as plain strings (see
player-choice-overlay.tsx
lines 59-67). Surfacing the toolbar there would let authors insert
tags that the runtime would then render as literal {wave}…{/wave}
text - a footgun, not a feature. Extending those surfaces to parse
rich text is a separate, deliberate runtime change.
Static formatting
These render synchronously - the text appears with the formatting
applied, no per-glyph animation. All four are present in the corpus
(across two sampled games: {i} × 317, {b} × 102,
{color=} × 59, {size=} × 12).
| Tag | Value |
|---|---|
| {b}…{/b} | - |
| {color=…}…{/color} | #rgb / #rrggbb hex, or a theme token (e.g. dialogue.accent) |
| {i}…{/i} | - |
| {size=…}…{/size} | 8-96 pt absolute, or -24…+24 relative |
Out-of-range values reject at parse time per G3.1 (same shared
validator as the effects catalog). {b} maps to CSS font-weight: 700,
{i} to font-style: italic; {color} and {size} override the
wrapped span (relative sizes stack with the surrounding context).
I {b}can't{/b} believe you {i}did{/i} that.{b} and {i} compose freely with surrounding plain text - same line, two static spans.
The {color=#ff5566}red light{/color} blinks once, then the {size=+6}whole{/size} room goes still.{color} accepts hex literals or theme-token paths; {size} accepts absolute (8-96) or relative (±24) values.
Animated formatting
These render per-glyph - each character in the wrapped span is its
own animated <span>. A soft per-span glyph cap sits above what any
textbox can hold, so real lines always animate per-glyph; a
pathologically long span falls back to whole-block animation.
| Tag | Value |
|---|---|
| {bounce}…{/bounce} | - |
| {pulse}…{/pulse} | - |
| {rainbow}…{/rainbow} | - |
| {vibrate}…{/vibrate} | - |
| {wave}…{/wave} | - |
{vibrate} jitters each glyph in place at low amplitude (nervousness,
fear, instability); {wave} sine-waves each glyph on the y-axis with a
per-glyph phase offset (sing-song, dreaminess, levity). Both are
Folio-native - not present in the Ren'Py corpus. They land with the
per-glyph renderer in Phase 3 Step 5.
She watched {vibrate}everything{/vibrate} unravel.{vibrate} jitters each glyph independently - a low-amplitude tremor that reads as nervousness.
They could hear the {wave}broadcast{/wave} from a mile out.{wave} sine-waves each glyph on the y-axis with a left-to-right phase offset; the traveling motion is what separates it from {vibrate}.
The monitor read {pulse}steady{/pulse}, for now.{pulse} swells and settles each glyph on a slow shared clock - breathing, not shaking.
She was {bounce}thrilled{/bounce} about it.{bounce} hops each glyph with a springy landing. The hoppy easing is what separates it from {wave}'s sine glide.
The sign spelled {rainbow}WELCOME{/rainbow} in lights.{rainbow} cycles each glyph through colour. It overrides any {color} on the same words, by design.
Appearance
Motion runs forever once the words are up. Appearance is a one-shot fired at each glyph's own reveal moment, so it reads as part of the typing rather than a state the text is in. With the default cadence many glyphs are mid-shot at once, which is where the look comes from.
| Tag | Value |
|---|---|
| {fade}…{/fade} | - |
| {pop}…{/pop} | - |
| {rise}…{/rise} | - |
{fade}Nothing was left of the place.{/fade}{fade} bleeds each glyph in over 600ms. Because the stagger is far shorter than the fade, the run arrives as a soft smear rather than one letter at a time.
{rise}Morning came anyway.{/rise}{rise} floats each glyph up into place as it fades in - the same overlap as {fade}, with a little lift.
And there it was: {pop}proof{/pop}.{pop} snaps each glyph in with a quick overshoot. Short and percussive, so it lands on a word rather than a sentence.
The per-span glyph budget applies to every per-glyph effect, motion and appearance alike: past it the span degrades to one whole-block wrapper, which for an appearance effect simply means the block arrives with no one-shot.
Special timing
These affect how the line types out, not how individual glyphs look. Same closed-grammar shape; Folio-native.
| Tag | Value |
|---|---|
| {glitch}…{/glitch} | - |
{glitch} types with intermittent garbled-character flashes that
resolve to the real text (corruption, malfunction, possession).
The red {glitch}ON AIR{/glitch} light warms the room.{glitch} garbles each non-whitespace char for ~150ms before settling on the real character. Whitespace is exempt so word-wrap boundaries stay clean.
Pacing
Pacing tags move the reveal clock itself: hold it, scale it, or skip
it. They combine freely with anything above, and with each other -
only {speed} and {instant} fight, since both answer the same
question.
| Tag | Value |
|---|---|
| {instant}…{/instant} | - |
| {pause=…} | 0.5 / 1 / 2 seconds |
| {speed=…}…{/speed} | 0.5 / 0.75 / 1.5 / 2 x the theme's typing rate |
{pause=N} holds for N seconds before the next character. {speed=N}
scales the typing rate inside the span against the theme's own
playback.textCps, so 2 is twice as fast and 0.5 is half. {instant}
drops the clock entirely for the wrapped words.
Wait...{pause=0.5} what did you just say?{pause} is a beat, not a wrapper. Dropped between two words it holds the reveal there and nowhere else.
{speed=0.5}Rain crawls{/speed} down the station glass.{speed=0.5} halves the typewriter rate inside the wrapped span. Watch the opening drag before the rest of the line snaps back to cadence.
{instant}You already knew{/instant}, didn't you?{instant} shows the wrapped words with no typing at all, so the line lands mid-thought.
Punctuation always dwells. A ., !, ? or … adds a short
beat, a ,, ; or : a shorter one, with no tag and no setting. A
run collapses to one beat at its end, so ... and ?! are single
pauses, and the last character of a line never dwells - the
click-to-continue marker owns that beat.
{slow} is a legacy alias. It parses forever and means exactly
{speed=0.5}, which is what a line rewrites itself to on its next
save. It is not in the picker and not in the table above.
What's deliberately out of v1
The corpus surfaces additional Ren'Py text tags that v1 does not
adopt. Each is a deliberate scope call, not an oversight. Ren'Py's
pacing tags are no longer among them: {w} and {p} import as
{pause}, {cps} as {speed}, and {fast} as {instant}.
| Tag | Corpus presence | Why out of v1 |
|---|---|---|
| {nw} | Heavy in Tier 3+ (252 hits) | "No wait - auto-advance" is a runtime mode, not a per-line tag in Folio. Authors set auto-advance on the player; the read-only migration report flags {nw} lines for re-checking after import. |
| {font=…} | 145 hits in one sandbox game | Font swaps are a theme concern - namebox / dialogue / choice already get distinct font tokens. Inline font swap is a power tool that opens the typography surface wider than v1 wants. |
| {image=…} | 321 hits in one sandbox game | Inline emoji / icon insertion is a sandbox-VN affordance with no v1 home. Re-evaluate if the matrix evidence grows past the current corpus presence. |
| {a=…} / {outlinecolor=…} / {u} | Sparse | Ren'Py UI screen affordances or styling minutiae that don't belong in narrative content. Dropped on import; the read-only migration report flags them inline. |
The closed-grammar discipline is the point. Adding a tag means deciding what authoring problem it solves and what the closed-form spelling is - not "well, Ren'Py has it."
Notes
A future tag lands via the catalog-growth process (matrix evidence
→ proposal → wiki page → runtime module → importer pattern
recognizer) - see docs/folio.md.
The importer lowers the supported tags above verbatim. Anything else
- including the Tier 3+ tags listed in the "deliberately out" table - is dropped on import and flagged inline in the read-only migration report with a stable per-tag issue code.