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.
0.6.4 shipped the faces but left the import opt-in, and the preview never made it — so out of the
box every family still fell through to system-ui, and four families were all that had a face at
all. Both halves are fixed in 0.9.0. If you are on an older version, import "@loom-dev/renderer/fonts"; as the first line of your entry does the same job.
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 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, 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.
A static gallery build emits ~2.8 MB of woff2 across the whole set. What a page downloads is
unchanged: Fontsource declares per-script unicode-range subsets, so a browser 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: 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: 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 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.
A registered face is never loaded at the moment it is registered: nothing has asked the browser for
it, and the canvas loom measures with never will, since measureText paints nothing and so starts no
download. The face only loads when text first paints in it.
Loom used to hear about that through document.fonts.ready, which is one promise for the cycle in
flight when it is read. Under a dev server the app boots through a graph of separate module
requests, long after the document settled — so the promise was already resolved, the listeners fired
at once against the fallback, and the face that downloaded seconds later notified nobody. The layout
stayed measured for a typeface that was no longer being painted until something unrelated, a resize,
forced a re-measure. A static build, where one bundle registers everything before the document is
done, came out right — which is why the same scene at the same version could render correctly
deployed and wrongly under npm run dev, and worst on Windows, where the fallback’s metrics are
furthest from the registered face.
0.9.2 listens for loadingdone on document.fonts instead, which fires at the end of every
cycle and has no such window.
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.
A registration is a claim about a file the page still has to fetch, and until 0.9.6 the engine ratio
was applied on the strength of that claim alone. When the fetch failed the browser painted the
fallback while loom went on sizing the text as though the registered face were there — every advance
off the wrong glyphs, wrapping in places the engine does not, AutomaticSize reporting a box that does
not fit the text drawn in it.
Only a dev server can land there: a static build carries its font files in its own output, so the face is always present. That is what made it read as a dev-only rendering bug rather than a font that failed to load. Against the same target at one width, a dev server whose face 404s wrapped a paragraph to ten lines where the build of the same source took nine.
0.9.6 applies the calibration only when the browser can actually paint the family and otherwise
measures the face it really has, which is self-correcting — a font-loading cycle drops the metric
caches and the label re-measures against whatever just landed. familyIsAvailable is exported for
hosts that register their own faces and want the same answer.
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.
A newline in Text breaks the line in Roblox — wrapped or not, RichText or not, exactly as <br/>
does — and a run of spaces stays a run of spaces. Loom measured it that way and then painted through
HTML’s defaults, white-space: normal and nowrap, which fold both away. A label written with line
breaks in it could measure as twenty-three lines and paint as seventeen: a box a hundred pixels taller
than the text inside it, with every sibling below pushed down by room nothing occupies.
0.9.3 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 — most text — 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 — 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 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.
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.