Loomguides

Fonts and text metrics

Which families ship, how to register the proprietary ones, and why `TextSize` is not a font size.

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

A browser ships none of Roblox’s typefaces. Left to system-ui, a family resolves to SF Pro on macOS, Segoe UI on Windows and Roboto on Linux — 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 lays out differently on each machine.

The preview loads the faces itself. The import sits in the globals module, injected ahead of your entry whichever frontend you use. A vide preview gets the same faces as a React one, with no 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.

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. Arial and Legacy need nothing. Every machine has Arial, and the registered Arimo 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 led by the right typeface, so a machine that has the real font uses it. That covers the proprietary Gotham and BuilderSans, plus Bodoni, Garamond, Cartoon, SciFi, Arcade, Fantasy, Antique and Highway. Otherwise it falls back and warns rather than drifting in silence.

Enum.Font carries the engine’s whole enum, all 53 items in its own order, so a scene naming any of them runs.

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. A legacy Enum.Font item folds the weight in, as in GothamBold. A FontFace carries the asset’s own family, sometimes a different word entirely, such as GothamSSm or SourceSansPro. Every spelling is matched by prefix, longest first so RobotoMono is never read as Roboto, so one registration covers them all and the weight suffixes come free.

The aliases worth knowing: GothamSSm → Gotham, SourceSansPro → SourceSans, Code → Inconsolata, Fredoka → FredokaOne, LegacyArial → Legacy, HighwayGothic → Highway. 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 registering late is fine and expected. A registration, or a @font-face finishing its download, invalidates every AutomaticSize bound from 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

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, 47% for Oswald. Text measured wider, wrapped earlier, and grew AutomaticSize boxes too large.

Loom divides by the face box, so TextSize means what the engine means by it. LineHeight follows. The engine’s pitch is TextSize-relative, so it is set in pixels off TextSize. 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, and it is not the number Roblox divides by. Roboto reports 1.17 where the engine sizes it as 1.14. So loom carries the engine’s own ratio for each family it registers, solved against TextService:GetTextBoundsAsync at TextSize 18. Twenty-four of the twenty-eight reproduce all 24 sampled glyphs exactly, and the rest are still closer than the browser’s. A family with no entry keeps the measured box, including anything you registered yourself and Gotham.

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. That comes out a few percent narrower and wraps a long paragraph at different words. The renderer caches half-pixel grapheme advances per font, invalidates them when a face changes, and keeps the fractional result. React and Vide measure from the same place, so a live preview and a compiled scene agree.

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

And one function decides every wrap. wrapLines is the single place. 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 and 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. The warning is raised while the text is being encoded, so 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”. 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.3–0.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.

It was measured against TextService:GetTextBoundsAsync, with Roboto at TextSize 18 and 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. The wrapped line count matched 34 times when CSS was doing the wrapping.

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