Phaser's setFont() splits the family on spaces
Short version: Phaser 4.1.0’s TextStyle.setFont() parses a font string with
font.split(' ') and keeps at most three tokens. Pass it
'bold 24px Cook Line Font' and the family becomes Cook — the last two words
are dropped on the floor. Quoting makes it worse, not better: you get 'Cook,
with a stray apostrophe.
Everywhere else, an unquoted multi-word family is fine. { fontFamily: 'Cook Line Font, Arial' } in a text style, document.fonts.load('16px Cook Line Font'), a raw ctx.font assignment — all three accept it and normalise it to
"Cook Line Font". The one method that can’t take it is the one that does its
own parsing.
I’m writing this because we had the opposite rule written down, in two files, as a settled fact. It was wrong.
The rule we had recorded
A comment at the top of src/style.css in the game repo, mirrored into that
project’s CLAUDE.md, said this:
It is two words, so it must be quoted at every one of those sites. Phaser builds a CSS font shorthand by concatenation (
bold 24px <family>), and an unquotedCook Line Font, Arialmakes that shorthand invalid, at which point the browser discards the whole declaration — size and weight included, not just the family.
Half of that is true. Phaser really does build a shorthand by concatenation, and a CSS shorthand really is all-or-nothing. The conclusion drawn from those two facts is what doesn’t survive contact with a browser.
Cook Line Font is a legal unquoted CSS family name. CSS Fonts Module Level
4, §2.1.1, gives the grammar as <font-family-name> = <string> | <custom-ident>+ — an unquoted family is a sequence of one or more
identifiers, not just one, which is precisely why Times New Roman works
unquoted. Quotes are required when a family name has
characters that aren’t valid in an identifier, or a first character that can’t
start one — a digit, most obviously. Spaces alone don’t qualify.
What the browser actually does
Canvas, in headless Chrome 151, assigning the concatenated string directly:
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = 'bold 24px Cook Line Font, Arial, sans-serif';
ctx.font; // "bold 24px \"Cook Line Font\", Arial, sans-serif"
ctx.font = "bold 24px 'Cook Line Font', Arial, sans-serif";
ctx.font; // "bold 24px \"Cook Line Font\", Arial, sans-serif"
Identical. The browser parsed the unquoted form, understood the family as three identifiers, and serialised it back in quoted canonical form. The size and the weight are on both.
The all-or-nothing behaviour the comment describes is real — it just needs a
genuinely invalid value to trigger it. These three collapse the whole
declaration to the 10px sans-serif default:
ctx.font = 'bold Cook Line Font, Arial'; // no size → "10px sans-serif"
ctx.font = 'bold 24 Cook Line Font, Arial'; // no unit → "10px sans-serif"
ctx.font = 'bold 24px 2Cool Font, Arial'; // digit-led → "10px sans-serif"
A missing size, a unitless size, or a family whose first identifier starts with a digit. Note what’s common to the first two and absent from our case: the size is the part that has to be there and has to have a unit. In a shorthand that a program assembles from separate variables, the size is also the field most likely to arrive as a bare number.
document.fonts.load is stricter still, and unlike canvas it tells you:
SyntaxError: Could not resolve '16 Cook' as a font.
It throws that for a unitless size, for a digit-led family, and — the one worth
knowing — for a family name with no size at all. document.fonts.load('Cook Line Font') throws; document.fonts.load('16px Cook Line Font') resolves. The
quoting is irrelevant to it either way.
Where Phaser assembles the string
Two lines in TextStyle, one in the constructor and one in update(),
both identical:
this._font = [ this.fontStyle, this.fontSize, this.fontFamily ].join(' ').trim();
That’s it — a three-element join. It reaches the canvas unmodified in
syncFont:
syncFont: function (canvas, context)
{
context.font = this._font;
},
So whatever you put in fontFamily is what lands in the shorthand, spaces and
all. Since the browser accepts multi-word families, that path is safe. The
danger isn’t in the join. It’s in the method that has to run the join
backwards.
setFont() parses by splitting on spaces
TextStyle.setFont() accepts either an object or a string. Given a
string, it does this:
var fontSplit = font.split(' ');
var i = 0;
fontStyle = (fontSplit.length > 2) ? fontSplit[i++] : '';
fontSize = fontSplit[i++] || '16px';
fontFamily = fontSplit[i++] || 'Courier';
Three indices, and no fourth. A family name of more than one word cannot survive this, because the words after the first are never read.
Running it against real Phaser 4.1.0 — a Phaser.Game in headless Chrome,
reading style._font back off the Text object after each call:
| Call | Resulting fontFamily |
Assembled _font |
|---|---|---|
setFont('bold 24px Cook Line Font') |
Cook |
bold 24px Cook |
setFont("bold 24px 'Cook Line Font'") |
'Cook |
bold 24px 'Cook |
setFont('24px Cook Line Font') |
Line |
24px Cook Line |
setFont({ fontFamily: 'Cook Line Font, Arial', fontSize: '24px', fontStyle: 'bold' }) |
Cook Line Font, Arial |
bold 24px Cook Line Font, Arial |
Row one is the plain truncation. Row two is why quoting isn’t a defence: the
opening quote is attached to the first word, so fontFamily becomes 'Cook, a
string with an unbalanced quote in it. Chrome is generous enough to recover
Cook from it when the shorthand hits the canvas, which means the damage stays
invisible.
Row three is the interesting one. With no style token, fontSplit.length > 2 is
still true, so the ternary consumes 24px as the font style, Cook as the
font size, and Line as the family. Phaser’s fontSize property is now the
string "Cook". The assembled 24px Cook Line then happens to be valid CSS by
coincidence — Chrome reads 24px as the size and Cook Line as the family — so
the text renders at roughly the right size and nothing looks broken. The
corruption only surfaces later, in whatever reads fontSize back.
Row four is the object form, and it’s clean. It skips the parser entirely.
A numeric fontSize is coerced to px
Phaser 4.1.0 handles the failure everyone expects. Passing a bare number as the
size is safe: both the TextStyle constructor and setFontSize
coerce it to a pixel string before it reaches the shorthand.
if (typeof size === 'number')
{
size = size.toString() + 'px';
}
setFontSize(24) and { fontSize: 24 } both produce 24px. Given that a
unitless size is one of the three things that genuinely does void the whole
declaration, this guard is doing more work than it looks like.
All 26 call sites already used the style object
Nothing changed in Cook Line’s rendering, which is the honest answer. All 26 places the game sets a Phaser text style use the object form, never the string:
const font = { fontFamily: "'Cook Line Font', Arial, sans-serif", fontStyle: 'bold', color: '#ffffff' };
That path was never at risk, and the quotes there were doing nothing. They’re harmless and they’re staying — quoting a family with spaces is conventional, and convention is worth something when a string is repeated 26 times. What changed is the comment above them, which now says what is actually true.
The document.fonts.load('16px "Cook Line Font"') call in main.ts keeps its
quotes for the same reason. The size in front of it, on the other hand, is
load-bearing: drop it and the call throws.
Why a wrong comment outlives wrong code
The rule was written prophylactically. Nobody hit the bug it describes, because the bug it describes doesn’t exist — someone reasoned from two true premises (Phaser concatenates a shorthand; CSS shorthands are all-or-nothing) to a conclusion neither of them supports, wrote it down with confidence, and the confidence is what made it survive.
That’s the failure mode worth naming. A wrong rule in a comment is more durable than a wrong line of code, because nothing runs it. Code that’s wrong eventually produces a symptom. A comment that’s wrong gets read, believed, and copied into the next project’s docs — which is exactly what happened here, twice, before a two-line browser test settled it in under a minute.
So: if a comment explains a mechanism rather than a decision, it’s a testable
claim, and it should carry the test. The replacement names the real hazard —
setFont()’s three-token split — which is checkable in a way that “must be
quoted” never was.
All of this is Phaser 4.1.0, pinned in the game’s package.json, with every
snippet from the v4.1.0 tag, and every browser result from headless
Chrome 151.
Five other things in this engine caught us the same way: the volume setter that
looks like a no-op, Scale.RESIZE and device pixels,
pointerup when you release off-canvas, the canvas sized
mid-rotation, and the scene instance reused across
restart(). This one is the outlier — every other post
here is about the engine behaving in a way we hadn’t read. This one is about us
being wrong on paper while the engine did nothing at all. The site-side
equivalent is a drop-shadow that had never once rendered: also invisible,
also survived on nobody checking.