Studio

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).

TagValue
{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.
I can't believe you did 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.
The red light blinks once, then the whole 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.

TagValue
{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.
She watched everything 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.
They could hear the broadcast 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.
The monitor read steady, for now.

{pulse} swells and settles each glyph on a slow shared clock - breathing, not shaking.

She was {bounce}thrilled{/bounce} about it.
She was thrilled 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.
The sign spelled WELCOME 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.

TagValue
{fade}…{/fade}-
{pop}…{/pop}-
{rise}…{/rise}-
{fade}Nothing was left of the place.{/fade}
Nothing was left of the place.

{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}
Morning came anyway.

{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}.
And there it was: proof.

{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.

TagValue
{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.
The red ON AIR 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.

TagValue
{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?
Wait... 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.
Rain crawls 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?
You already knew, 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.

See also

  • say - dialogue verb; text-tag content lives inside its quoted string
  • narrate - narration verb; same tag grammar applies
  • @flash - stage-level color punctuation, distinct from inline {color=…} tags