Fonts & Text

Every glyph is decided before you ship

Fonts are project assets, baked into MSDF atlases at import time — distance fields, not bitmaps, so one atlas is crisp at any size. Nothing is fetched, shaped or rasterised while your game is running. That buys you a text stack with no hitches and no surprises at load, and it costs you one thing you have to plan for: coverage is fixed at bake time. This page is how that works, and every sharp edge it has.

8
charset blocks
~607
glyphs by default
9
weights from one file
~90
curated kern pairs
15
ligature candidates
0
fonts parsed at runtime

Overview #

One idea explains almost everything on this page: the engine decides which glyphs exist when you import the font, not when you draw the text.

Baked, not loaded

Importing a .ttf produces an atlas plus metrics. The game ships those. There is no font file to parse at startup and no first-frame stall while glyphs rasterise.

MSDF, not bitmaps

Glyphs are stored as multi-channel distance fields. One 48-em atlas draws a 12px label and a 200px title from the same texels, with sharp corners — that is the whole reason to bake.

Coverage is a decision

You choose which Unicode blocks to bake. That choice is the atlas budget, and it is also the complete list of characters your game can display in that font.

The trade, stated plainly

A browser can render any character in any font because it carries a shaper, a rasteriser and a fallback chain, and pays for them on every page. A game HUD does not need that, and paying for it per frame is the wrong trade. So the engine does the expensive part once, on your machine, and ships the answer. What you give up is the ability to render a character you did not plan for — which is fine for a HUD and a real constraint for anything showing user-typed text.

From .ttf to a glyph #

Six steps, all of them on your machine.

  1. 01

    Drop the file in

    A .ttf or .otf lands in /content. It gets a .meta sidecar holding its guid — commit the sidecar with the font, or a rename mints a new guid and every reference breaks.

  2. 02

    Pick coverage

    The asset inspector holds the import settings: which Unicode blocks to bake, the atlas size and the distance range. Coverage is decided here, once — not at runtime.

  3. 03

    Plan the glyphs

    The charset is shaped once to work out which glyph each codepoint maps to, which glyphs are actually used, the kerning deltas and the available ligatures. This plan is weight-independent, so a nine-weight family is planned once.

  4. 04

    Bake the atlas

    Each weight bakes into an MSDF atlas — a distance field, not a bitmap, so one atlas scales to any size without blurring. Output is a PNG plus a metrics JSON, cached beside the project.

  5. 05

    Register at load

    The editor and the player load the metrics for every font a mounted screen references and register each face under its family name. From then on font-family and font-weight resolve against that registry.

  6. 06

    Ship

    A build copies the atlases next to the game and inlines the metrics. Only fonts a shipped screen references are included — see the tree-shaking gotcha below.

Import settings #

Four knobs on the font asset. Together they decide what your text can say and what the atlas costs.

SettingValuesWhat it changes
Charset blocks8 checkboxesWhich codepoints get a glyph. Defaults cover EN / RU / most European languages — everything except Greek and Symbols. Enabling a block the font does not have is harmless: those glyphs are simply skipped.
Custom charseta raw spec stringAppended to the blocks. Ranges are [start, end], single codepoints are 0x… or decimal, comma-separated: [0x3040, 0x30FF], 0x2603 adds kana and a snowman.
Atlas size32 | 40 | 48 | 64 (default 48)The em size each glyph is rasterised at. Bigger keeps more of the outline’s detail and costs atlas area quadratically. This is not the size text renders at — MSDF scales freely; it is the fidelity of the field.
Distance range4 | 6 | 8 | 12 (default 4)How many texels the distance field spans. It is the budget for effects: outlines, shadows and stroke can only spread about this far before they saturate. Raise it if text-stroke looks clipped; it costs atlas area.

Charset blocks

BlockRange~GlyphsOn by default
Basic Latin (ASCII)U+0020–U+007E95yes
Latin-1 (accents)U+00A0–U+00FF96yes
Latin Extended-AU+0100–U+017F128yes
CyrillicU+0400–U+04FF256yes
GreekU+0370–U+03FF144no
PunctuationU+2010–U+2027 + ‰ ′ ″ ‹ ›28yes
Currency & №€ ₽ ₴ №4yes
Symbols & arrows™ ← ↑ → ↓ • ● ✓ ✗9no

The defaults come to roughly 607 glyphs — enough for English, Russian and most of Europe. Greek and the arrow symbols are off because most projects never use them and every glyph costs atlas area.

Custom charset

Appended to whatever the blocks selected. Ranges in brackets, single codepoints bare, comma separated — this adds kana, a snowman and a heart:

[0x3040, 0x30FF], 0x2603, 0x2764

Coverage #

What happens when you ask for a character that was not baked — and why it is never the wrong character.

Only baked glyphs render

Coverage is frozen at import. There is no fallback font, no system font, no download — if a codepoint was not in the charset when the atlas was baked, the engine has no glyph for it.

A missing glyph vanishes, it does not tofu

An uncovered codepoint contributes zero advance and loses its letter-spacing too, so the line tightens up rather than showing a replacement box. That is deliberate — but it does mean a missing character is easy to overlook in a screenshot.

It is never the *wrong* glyph

The codepoint→glyph map baked into the atlas is authoritative. An uncovered character resolves to nothing at all — it can never resolve to some unrelated baked glyph, which is the classic failure mode of index-keyed atlases.

Enabling a block a font lacks is free

The bake plan drops any codepoint the font does not actually map, so ticking Cyrillic on a Latin-only face costs nothing and warns about nothing. Check the preview window’s coverage grid to see what you really got.

A few marks cannot be mapped 1:1

Where several codepoints shape into one cluster — the Cyrillic combining marks U+0483–U+0489 are the case in the default set — no honest single glyph exists for each, so they stay uncovered rather than being filed under a neighbour’s glyph.

Weights & families #

A variable font is worth importing for this reason alone: one file becomes a whole family with real faces.

A variable font becomes real weights

If the file carries a wght axis, every standard weight inside its range — up to nine, 100 through 900 — bakes its own atlas. One universal .ttf gives you a whole family, and font-weight: 300 picks a genuinely lighter face.

A static font is one face

One file, one atlas, one weight. font-weight then resolves to that same face no matter what you write, because nearest-weight matching has exactly one candidate. There is no synthetic bolding and no synthetic oblique — the engine never fakes a weight or a slant it was not given.

Weight matching is CSS’s own rule

Exact match first, then the spec’s directional-nearest: 400500 prefer heavier before lighter, below 400 goes lighter first, above 500 goes heavier first. It runs inside the family you already selected — it never crosses into another family looking for a closer weight.

Italic is a filter, not a transform

font-style: italic narrows the candidate faces to the italic ones. If the family has none, the upright face is used silently — a separate italic file, imported alongside, is how you get real italics.

font-variation-settings: "wght" overrides the weight

Only for face selection — it picks which baked atlas to use. Other axes (wdth, slnt, opsz, custom) parse and are stored, but nothing instances them at runtime.

Kerning & ligatures #

Both are derived at import and frozen into the metrics. Both are deliberately partial, and it is better to know exactly how partial.

Kerning is ~90 curated Latin pairs

Modern fonts keep kerning in a table the atlas baker does not read, so the importer re-derives it by shaping a fixed pair list — caps against diagonals and rounds (AV, Ty, Wo), r/v/w/y against following letters, letters before punctuation. Deltas under 0.4% of the em are dropped to keep the table small.

Cyrillic and Greek get zero kerning

The curated list is Latin. Non-Latin scripts, and any Latin pair outside the list, render at their plain advances. font-feature-settings: "kern" 0 does not turn the curated kerning off.

Ligatures are baked, not shaped

Fifteen standard candidates — ffi ffl ffb ffh ffk ffj ff fi fl ft fj fb fh fk ij — are probed against the font at import. Each one that genuinely collapses to a single glyph gets that glyph baked and a substitution rule. Everything else is left alone.

"liga" 0 is the one feature tag that does anything

It disables the substitution pass. Every other OpenType tag you write in font-feature-settings is stored and ignored: there is no runtime shaper, so no contextual alternates, no discretionary or historical ligatures, no arbitrary substitutions.

Using a font #

Three ways to name a face, tried in order. The first one that resolves wins.

OrderWhat you writeWhat happens
1font: asset('ui/Inter.ttf')A direct face override — the engine-specific property, and the strongest claim. It is not the CSS font shorthand; it takes an asset reference and nothing else. If the guid is not registered, resolution silently falls through to step 2.
2font-family: Inter, "Noto Sans"Walks the stack. The first family with any registered face wins outright — it does not keep looking for a better weight in a later family. Then the italic filter, then nearest weight.
3(nothing)The default font. Which font that is comes from the strength of the claim — a project that says so outright, else the engine’s builtin face — never from whichever file happened to finish loading first.
/* 1 — the direct reference. Strongest claim, and a real build dependency. */
.title {
  font: asset('ui/Inter-Variable.ttf');
  font-size: 28px;
  font-weight: 700;            /* a variable font baked 100..900 → a real Bold face */
  letter-spacing: 0.02em;
}

/* 2 — by family name. Resolves against whatever the project registered.
   Keep the name a LITERAL: a family built at runtime is not a build dependency. */
.body {
  font-family: Inter, "Noto Sans";
  font-size: 15px;
  line-height: 1.45;           /* an advance, not half-leading — see Gotchas */
}

/* 3 — nothing. Falls back to the project default font. */
.plain { font-size: 14px; }

/* Effects live inside the atlas's distance-range budget. */
.badge {
  font-size: 12px;
  text-transform: uppercase;
  text-stroke: 1px #000;       /* width AND colour, both required */
  text-shadow: 0 1px 2px #00000099;
  text-rendering: crisp;       /* snap glyph quads to the pixel grid */
}

/* Truncation needs a finite width. '…' must be baked, or you get '...' */
.label {
  width: 180px;
  white-space: nowrap;
  text-overflow: ellipsis;
}

Text properties worth reading twice

PropertyNotes
font-sizeA scalar length, plus %. % and em mean the same thing here and are both parent-relative. Everything else in text layout is font-size-relative.
line-heightnormal is 1.2. Resolves to a per-line advance, and block height is exactly lines × advancethere is no half-leading, so a tall line-height does not add space above the first line the way a browser does.
letter-spacing / word-spacingAdded to every codepoint’s advance including the last on a line. Word spacing applies at U+0020 only — not NBSP, not tab, not ideographic space.
text-decorationInherited here, unlike CSS. Geometry comes from font-size fractions rather than the font’s own underline metrics. Only double changes the rendering; dotted, dashed and wavy all draw solid.
text-shadowEach layer is a full extra glyph run. The colour is optional and defaults to opaque black — CSS would use the text colour. Blur widens the distance-field band and is capped by the bake.
text-strokeWidth and colour are both required. It is a distance-field expansion from the glyph centre, not a true outline — and it lives inside the same effect budget as the shadow.
text-overflow: ellipsis / line-clampBoth need a finite width. The ellipsis character is when it was baked, and falls back to three full stops when it was not.
text-rendering: crispSnaps each glyph quad to the physical pixel grid — quad size is unchanged, so advances survive. Suppressed under any transform.
-ui-materialOn a <text> node a custom material shades the glyphs, and the node’s background stack still paints underneath.

The full list — every text property, every value it accepts and every deviation — is in the UI property reference.

Text in the world #

The same font assets also drive text that lives in the scene rather than on the HUD.

<text> in a screen

Styled with CSS. font-size is in logical pixels. Gets the whole text feature set — wrapping, clamping, decorations, shadow, stroke.

core.Text component

A component on an entity, sorted and lit with the rest of the scene. Its Font Size is a cap height in metres — the default is 0.32, which reads as large signage. Fields: text, font, size, line height, align, colour, sorting layer and order.

The unit difference is the trap. A font size that looks right on a HUD is a value in pixels; typing the same number into a world-space core.Text asks for letters that many metres tall. Both paths read the same baked atlas, so everything on this page about coverage, weights and kerning applies equally to both.

Atlas quality #

Atlas size and distance range are the only two quality knobs, and they trade against texture area.

Upscale is free, downscale is not

A distance field is resolution-independent going up — a 48-em atlas renders crisply at 200px. Going down is where it bites: no mips are generated, so a 48-em atlas drawn at roughly 8px aliases. Size UI text with that in mind, or bake a smaller atlas for tiny text.

Sharp corners round off

The field is only valid within its distance range of the outline. Past that, a sharp corner reads as rounded. It is most visible on display faces with hairline serifs at large sizes — raising the distance range or the atlas size pulls it back.

Effects share one budget

With the default bake, text-stroke and text-shadow have a couple of screen pixels of usable spread. Beyond that they saturate rather than grow — the value keeps rising and the picture stops changing. Raise the distance range if you need heavier outlines.

Atlas area is the real cost

Glyph count × atlas size × distance range all multiply into one texture. Cyrillic alone is 256 glyphs. If a font is only ever used for a Latin HUD, turning the other blocks off is the cheapest win available.

The important part

Gotchas #

Ranked by how much of your afternoon they take. Everything here is current behaviour, not a roadmap complaint.

bites hard

font-family is not an asset reference — a build can drop the font

A build ships only the fonts a screen actually uses. font: asset(…) is a real dependency and is always kept. A family name is just a string, so the exporter keeps a font when its family name appears literally in a shipped screen. Build the name at runtime — :style="{ fontFamily: theme.fontName }" — and the font is not in the bundle, and the text silently renders in the default face. Reference such fonts with font: asset(…) somewhere, or keep the family name as a literal in the stylesheet.

bites hard

An unknown font guid falls back silently

font: asset(…) pointing at a font that failed to bake, or was never registered, does not warn at draw time. Resolution falls through to the font-family stack, and then to the default font. Text that "looks slightly wrong" rather than missing is usually this.

bites hard

Changing charset requires a rebake

Charset, atlas size and distance range are import settings. Editing them marks the asset dirty; you have to hit Apply to rebake. Nothing about a running screen re-reads the font until that finishes, and a build made before the rebake ships the old coverage.

worth knowing

Combining marks are effectively unsupported

U+0300–U+036F is in no charset block, so decomposed sequences lose their marks entirely — only precomposed Latin-1 and Latin-Ext-A forms render. Even where a mark is baked, it is placed at the current pen and then advanced past, so any non-zero letter-spacing pushes it off its base letter. Normalise your strings to precomposed form.

worth knowing

The ellipsis is a real glyph

text-overflow: ellipsis uses , which lives in the Punctuation block. Turn that block off and truncation quietly degrades to three full stops — same meaning, different width, so a tight label may re-wrap.

worth knowing

No half-leading

Block height is exactly line count × line advance. A browser splits extra line-height above and below the text; here it all goes into the advance. Ports from CSS usually need their vertical padding retuned, not their line-height.

worth knowing

font-weight on a static font does nothing

No warning, no visual change — nearest-weight matching just returns the one face there is. If a design calls for bold, import the bold file, or use a variable font and let the importer bake the weights.

worth knowing

Font baking needs the desktop editor

The atlas bake runs in the editor process. A project opened without it can load already-baked fonts fine, but importing a new one reports that the atlas could not be built — the font asset exists and renders nothing.

minor

Exotic punctuation is missing more often than you expect

Em dashes, curly quotes, and arrows each live in a specific block. Build icons out of <view>s — rotated squares, bars, rings — rather than out of glyph art, and they will never depend on what a face happens to cover.

minor

One font per text node

A <text> node resolves exactly one face for its whole run. There is no per-glyph fallback chain, so a Latin font plus a Cyrillic font does not compose — bake one face that covers both, or split the string across nodes.

minor

font-feature-settings is nearly a no-op

Tags are parsed, stored and then ignored, with the single exception of "liga" 0. Writing "ss01" 1 or "tnum" 1 changes nothing and reports nothing — there is no runtime shaper to apply them.

minor

Text is the only place text renders

Elements nested inside a <text> node are inert. Mixed-format text — a bold word inside a sentence — is several sibling <text> nodes in a row-direction container, not markup inside one.

Recipes #

The questions that come up, and the shortest honest answer to each.

My text is invisible / some characters are missing

Open the font asset and check the coverage grid in the preview window. If the character is not there, tick the block that contains it (or add it to the custom charset) and hit Apply. Remember that a missing glyph takes no space at all, so the line looks tight rather than broken.

I need Japanese / Chinese / Korean

Add the ranges through the custom charset — [0x3040, 0x30FF] for kana, and the CJK ideograph range if you need it. Be deliberate: full CJK is tens of thousands of glyphs and the atlas grows with every one. Bake only the subset your strings actually use, or split UI text into a dedicated font asset.

My outline / shadow stops getting thicker

You have hit the distance-range budget. Raise Distance range from 4 to 8 and rebake. The atlas gets bigger; the effect gets its headroom back.

Small text looks fizzy

A large-em atlas drawn very small aliases, because there are no mips. Either bake a smaller atlas size for that font, or raise the rendered size. Adding text-rendering: crisp snaps glyph quads to the pixel grid and helps for static UI text.

I want the whole family from one file

Import a variable font with a wght axis. Every standard weight in its range bakes automatically, all under one family name, and font-weight then resolves against real faces. One import, nine weights.

My atlas is huge

Turn off blocks you do not ship — Cyrillic alone is 256 glyphs — and drop the atlas size from 64 to 48 or 40. Check the glyph count the inspector shows before you rebake; it is the number that drives everything else.

Not supported #

What a bake-first text stack cannot do, and what that costs you in practice.

Not supportedWhat that means for you
@font-faceFonts are project assets, imported through the content pipeline. A stylesheet cannot pull one in, and the at-rule is reported and ignored.
System fonts / generic familiessans-serif and monospace are just names that must match a registered family. Nothing resolves to the machine’s installed fonts.
Runtime shapingThe glyph plan is fixed at import. That rules out complex-script shaping — Arabic joining, Indic reordering — and any arbitrary OpenType substitution.
Bidirectional textThere is no bidi pass. RTL strings lay out left to right in logical order.
Per-glyph font fallbackOne face per text node, chosen once. A codepoint the face lacks is not looked for elsewhere.
Colour glyphs and emojiEvery glyph is a distance field, so an emoji font’s colour layers have nowhere to go. Use images for emoji-like content.
Dynamic / streaming coverageThe atlas does not grow at runtime to cover text it has not seen. Unbounded coverage — arbitrary user-typed text, full CJK — is a planned second-generation feature, not something today’s bake does.
Synthetic bold and obliqueThe engine never fakes a face it was not given. Import the real weight or style.

Now go style it

Fonts are one asset type in a UI stack built around a real CSS engine — the cascade, @layer, @media, custom properties and twelve builtin controls all sit on top of what this page describes.