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.
On a version older than 0.9.0, put import "@loom-dev/renderer/fonts"; first in your entry.
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 family | Typeface |
|---|---|
SourceSans* | Source Sans 3 |
Roboto* | Roboto |
RobotoMono* | Roboto Mono |
RobotoCondensed | Roboto Condensed |
Inconsolata / Code | Inconsolata |
Arimo | Arimo |
Jura Merriweather Nunito Oswald Ubuntu TitilliumWeb | their own |
JosefinSans GrenzeGotisch Sarpanch Michroma | their own |
AmaticSC Bangers Creepster DenkOne Fondamento | their own |
IndieFlower Kalam LuckiestGuy PatrickHand PermanentMarker SpecialElite | their own |
FredokaOne | Fredoka — 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.
A static gallery build emits ~2.8 MB of woff2 across the whole set. Fontsource declares per-script unicode-range subsets, so a page fetches only the families and scripts a scene actually paints with.
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" }],});pnpm add -D @loom-dev/rendererOmit 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
| Field | Type | Notes |
|---|---|---|
family | string | Required. 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. |
fallback | string | Appended 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.
A registered face is not loaded at the moment it is registered. Nothing has asked the browser for it, and measureText paints nothing, so the face only loads when text first paints in it. Loom listens for loadingdone on document.fonts, which fires at the end of every cycle, and re-measures then.
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.
A registration is a claim about a file the page still has to fetch. Applying the engine ratio on that claim alone would paint the fallback while sizing the text as though the registered face were there.
So the calibration applies only when the browser can actually paint the family, and otherwise loom
measures the face it really has. It is self-correcting: a font-loading cycle drops the metric caches
and the label re-measures. familyIsAvailable is exported for hosts registering their own faces.
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.
A newline in Text breaks the line in Roblox, wrapped or not and RichText or not. A run of spaces stays a run of spaces. Loom paints pre-wrap when the label wraps and pre when it does not, so the paint has the breaks the measurement counted. Text with no newlines and no double spaces is unaffected.
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 paintedand measured in the system fallback instead, which is a different typeface per OSand does not match the engine's layout. Register one with registerFont("Gotham",{ family, faces }), or import "@loom-dev/renderer/fonts" for the families Robloxlicenses 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 returnstruefor 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 whatfamilyIsAvailableexposes. - A registration is not a face. Until
0.9.6the 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.