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.
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.
- 01
Drop the file in
A
.ttfor.otflands in/content. It gets a.metasidecar holding its guid — commit the sidecar with the font, or a rename mints a new guid and every reference breaks. - 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.
- 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.
- 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.
- 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-familyandfont-weightresolve against that registry. - 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.
| Setting | Values | What it changes |
|---|---|---|
| Charset blocks | 8 checkboxes | Which 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 charset | a raw spec string | Appended to the blocks. Ranges are [start, end], single codepoints are 0x… or decimal, comma-separated: [0x3040, 0x30FF], 0x2603 adds kana and a snowman. |
| Atlas size | 32 | 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 range | 4 | 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
| Block | Range | ~Glyphs | On by default |
|---|---|---|---|
| Basic Latin (ASCII) | U+0020–U+007E | 95 | yes |
| Latin-1 (accents) | U+00A0–U+00FF | 96 | yes |
| Latin Extended-A | U+0100–U+017F | 128 | yes |
| Cyrillic | U+0400–U+04FF | 256 | yes |
| Greek | U+0370–U+03FF | 144 | no |
| Punctuation | U+2010–U+2027 + ‰ ′ ″ ‹ › | 28 | yes |
| Currency & № | € ₽ ₴ № | 4 | yes |
| Symbols & arrows | ™ ← ↑ → ↓ • ● ✓ ✗ | 9 | no |
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: 400–500 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.
| Order | What you write | What happens |
|---|---|---|
| 1 | font: 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. |
| 2 | font-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
| Property | Notes |
|---|---|
font-size | A 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-height | normal is 1.2. Resolves to a per-line advance, and block height is exactly lines × advance — there 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-spacing | Added 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-decoration | Inherited 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-shadow | Each 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-stroke | Width 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-clamp | Both 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: crisp | Snaps each glyph quad to the physical pixel grid — quad size is unchanged, so advances survive. Suppressed under any transform. |
-ui-material | On 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.
Gotchas #
Ranked by how much of your afternoon they take. Everything here is current behaviour, not a roadmap complaint.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 supported | What that means for you |
|---|---|
@font-face | Fonts 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 families | sans-serif and monospace are just names that must match a registered family. Nothing resolves to the machine’s installed fonts. |
| Runtime shaping | The glyph plan is fixed at import. That rules out complex-script shaping — Arabic joining, Indic reordering — and any arbitrary OpenType substitution. |
| Bidirectional text | There is no bidi pass. RTL strings lay out left to right in logical order. |
| Per-glyph font fallback | One face per text node, chosen once. A codepoint the face lacks is not looked for elsewhere. |
| Colour glyphs and emoji | Every glyph is a distance field, so an emoji font’s colour layers have nowhere to go. Use images for emoji-like content. |
| Dynamic / streaming coverage | The 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 oblique | The 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.