Loomguides

Fonts and text metrics

The preview loads the engine's own typefaces and measures text the way the engine does — which families ship, how to register the proprietary ones, why TextSize is not a font size, and where a line breaks.

Roblox ships its own typefaces. A browser has none of them.

Loom names the Roblox families in CSS — font-family: "Gotham", system-ui, … — and until 0.6.4 loaded nothing behind them, so on a machine without the font installed every family resolved to system-ui: SF Pro on macOS, Segoe UI on Windows, Roboto on Linux. Three typefaces, three sets of advance widths. That is not only a paint difference: AutomaticSize and TextWrapped are driven by measuring those widths, so the same scene laid out differently on each machine, and nothing pointed at the font as the reason.

As of 0.9.0 the preview loads the faces itself. The import sits in the globals module, which is injected ahead of your entry whichever frontend you use, so a vide preview gets the same faces as a React one and neither needs a line of configuration.

The families that ship a face

Fontsource woff2 in the bundle — no CDN, nothing installed on the machine. These are the actual fonts the engine draws with, so the metrics are the engine’s rather than an approximation of them.

Roblox familyTypeface
SourceSans*Source Sans 3
Roboto*Roboto
RobotoMono*Roboto Mono
RobotoCondensedRoboto Condensed
Inconsolata / CodeInconsolata
ArimoArimo
Jura Merriweather Nunito Oswald Ubuntu TitilliumWebtheir own
JosefinSans GrenzeGotisch Sarpanch Michromatheir own
AmaticSC Bangers Creepster DenkOne Fondamentotheir own
IndieFlower Kalam LuckiestGuy PatrickHand PermanentMarker SpecialElitetheir own
FredokaOneFredoka — Google folded “Fredoka One” into Fredoka’s heavier weights, so this is the one approximation

Twenty-eight families, all OFL-1.1 apart from Ubuntu, which is under the Ubuntu Font Licence. Arial and Legacy need nothing: every machine has Arial, and Arimo — which is registered — is metric-compatible with it, so those stacks land on the right advance widths either way.

Named, but not loaded

The rest of the engine’s list resolves to a stack that leads with the right typeface and warns instead of drifting in silence: Gotham and BuilderSans (proprietary), and Bodoni, Garamond, Cartoon, SciFi, Arcade, Fantasy, Antique, Highway — faces either licensed to Roblox or with no identity loom can vouch for. On a machine that happens to have the real font installed the family’s own name heads its stack, so it is used; otherwise it falls back and says so.

Enum.Font carries the engine’s whole enum, all 53 items in its own order. Before 0.9.0 it held the sixteen loom happened to paint, so Enum.Font.Jura was undefined and a scene that named it crashed before drawing anything.

Registering a face yourself

registerFont installs a typeface for one Roblox family. A project that has the Gotham files makes exactly the call the shipped module makes for the open families:

import { registerFont } from "@loom-dev/renderer";
import gotham from "./fonts/Gotham-VF.woff2";
registerFont("Gotham", {
family: "Gotham",
faces: [{ src: gotham, weight: "100 900" }],
});
The renderer is a transitive dependency; import it directly and it needs declaring
pnpm add -D @loom-dev/renderer

Omit faces when the page already provides the family — a <link> to a font CDN, your own @font-face, or a font the machine has installed. Then the registration only has to name it:

registerFont("SourceSans", { family: "Source Sans 3 Variable" });

A registration replaces whatever loom shipped for that family, so this is also how you swap one out.

The registration shape

FieldTypeNotes
familystringRequired. The CSS family to paint and measure with. When faces is given, this is also the name they are declared under.
faces{ src, weight?, style?, display? }[]@font-face rules to declare. src is a URL the page can load — usually a bundler-emitted import. weight defaults to 400 and takes a range ("200 900") for a variable font; style defaults to normal; display defaults to swap.
fallbackstringAppended behind family. Defaults to that family’s own default stack.

One key per family

Roblox names a family several ways: the legacy Enum.Font item folds the weight in (GothamBold, ArimoBold), and a FontFace carries the asset’s own family, which is sometimes a different word entirely (GothamSSm, SourceSansPro, and Enum.Font.Code is the Inconsolata family). Every spelling is matched by prefix, longest first so RobotoMono is never read as Roboto, so one registration covers every spelling and the weight suffixes come along for free.

The aliases worth knowing: GothamSSmGotham, SourceSansProSourceSans, CodeInconsolata, FredokaFredokaOne, LegacyArialLegacy, HighwayGothicHighway. A name outside the family table falls to the generic sans stack and is not registrable.

A late face re-lays-out

Text bounds are measured against whatever the browser has at the time, so a face that arrives after the first paint would otherwise leave a layout measured in the fallback. Registering late is therefore fine and expected: a registration — or a @font-face finishing its download — invalidates every AutomaticSize bound that came out of the old stack, and both adapters re-measure.

If you need the hook yourself
import { onFontsChanged } from "@loom-dev/renderer";
const stop = onFontsChanged(() => {
// every text measurement taken before this is stale
});

clearRegisteredFonts() takes every registration back out, along with the @font-face rules loom added — useful in tests, and rarely otherwise.

TextSize is not a font size

Having the right typeface is half of matching the engine. The other half is drawing it at the right size, and the two properties do not mean the same thing.

Roblox fits the whole face into TextSize — ascender to descender, which is why a one-line label measures exactly TextSize tall. CSS font-size sets the em square instead, and a face’s ascent plus descent runs well past 1em. Painting font-size: TextSize therefore drew every glyph too big by that font’s own ratio: 17% for Roboto, 18% for Jura, 25% for Merriweather, 47% for Oswald. Everything downstream inherited it — text measured that much wider, so it wrapped that much earlier, so AutomaticSize boxes came out taller and wider, and a card sized to its text overran the column meant to hold it. All of it looked like a wrap bug and none of it was one.

Since 0.9.3 loom divides by the face box, so TextSize means what the engine means by it. LineHeight follows: the pitch the engine spends is TextSize-relative rather than font-size relative, so it is set in pixels off TextSize, and a <font size="…"> run inside RichText converts through the metrics of the face that run lands in.

Which box to divide by is itself a measurement. The browser reports one (fontBoundingBoxAscent + Descent) and it is not the number Roblox divides by — Roboto reports 1.17 where the engine sizes it as though it were 1.14, so every glyph came out about 2.6% small. Since 0.9.5 loom carries the engine’s own ratio for each family @loom-dev/renderer/fonts registers, solved against TextService:GetTextBoundsAsync per-glyph advances at TextSize 18. Twenty-four of the twenty-eight reproduce all 24 sampled glyphs exactly; FredokaOne, Merriweather, Nunito, Oswald and DenkOne do not, and their fitted ratio is still closer than the browser’s. A family with no entry — anything you registered yourself, Gotham included — keeps the measured box, which is the honest answer when nobody has solved the engine’s.

Where a line breaks

The engine does not measure a string the way a browser does, in two ways that compound.

It spends each displayed grapheme on a half-pixel boundary. Canvas measurement shapes and kerns a whole run with fractional advances, which can come out a few percent narrower and wrap a long paragraph at different words. Since 0.9.4 the renderer caches half-pixel grapheme advances per font, invalidates them when a face changes, and keeps the fractional result rather than rounding it to a whole pixel. React and Vide take their measurements from the same place, so a live preview and a compiled scene no longer disagree — before that, the React adapter measured TextBounds itself while a compiled scene went through the renderer.

It kerns. AV is 19.5 units wide where its glyphs are 10.5 and 10 alone, so since 0.9.5 shapedTextWidth adds the run’s kerning, quantized once for the run.

And one function decides every wrap. A label’s box used to come from the measurer while the glyphs inside it were left to CSS, which wraps on its own kerned run widths — so a label could reserve nine lines and paint eight, ending short of a box built for it and breaking at different words than Studio. Since 0.9.5 wrapLines is the single place a wrap is decided: measurement asks it how many lines a label needs and the text layer asks it where to put the breaks it paints, keeping them in white-space: pre. RichText runs go through the same wrap with the line carried across runs, each measured in the font its <font> tag gave it.

The warning

A family some text actually asked for, with nothing loaded behind it, is reported once:

loom: no face is loaded for the Roblox font family "Gotham" — its text is painted
and measured in the system fallback instead, which is a different typeface per OS
and does not match the engine's layout. Register one with registerFont("Gotham",
{ family, faces }), or import "@loom-dev/renderer/fonts" for the families Roblox
licenses openly.

Two details make it trustworthy rather than noisy:

  • It waits for the font loading cycle to finish, and re-queues while one is still in flight. A face that is downloading is late, not missing — and since the warning is raised while the text is being encoded, the paint that asks for the face may only just have started.
  • Availability is decided by probe-string width, not document.fonts.check(). That method answers “would this font specification resolve”, and an unknown family resolves — to the fallback — so it returns true for a family nobody has. Loom measures a probe string against two generics with wildly different metrics instead: a real family shifts at least one of them, a missing one leaves both exactly where the generic put them. This is what familyIsAvailable exposes.
  • A registration is not a face. Until 0.9.6 the audit skipped any family that had one, on the assumption that a registration means a face. A registered face whose file never arrived is now reported like any other missing one, rather than being quietly mis-measured.

Arial and Legacy are exempt. Both land on a font every machine has, so there is nothing to load and nothing to warn about.

What is still approximate

Loading the right face pins the typeface, and 0.9.30.9.6 pinned the size, the advances and the wrap. What is left is that a browser’s text engine is still not Roblox’s: shaping, hinting and sub-pixel rounding differ.

Measured against TextService:GetTextBoundsAsync — Roboto at TextSize 18, a long paragraph laid out at 50 widths from 320 to 1300 — the painted line count matches the engine at 49 of the 50. String widths are exact on 6 of 10 sampled strings and never off by more than half a unit; they were off by up to 9 before 0.9.5, and the wrapped line count matched 34 times when CSS was doing the wrapping.

What remains is that the measurement runs about a percent roomy, so text wraps a hair early rather than overflowing its box — the safe direction to be wrong in. Before 0.9.0 this was not a rounding difference at all; it was a different font.