Fonts and Languages¶
SimVX ships its own fonts, so a game draws the same typeface on every machine,
whether or not the player has any fonts installed. Choosing a different one, and
adding the scripts your game needs, is a few keys in simvx.toml:
[rendering]
# The face every string is drawn in. Omit it to keep the bundled Noto Sans.
font = "assets/fonts/Inter-Regular.ttf"
# One face per script the bundled font does not cover. Ship these beside your
# game and they work on a machine with no fonts installed at all.
fallback_fonts = [
"assets/fonts/NotoSansJP-Regular.ttf", # Japanese
"assets/fonts/NotoSansSC-Regular.ttf", # Simplified Chinese
]
# Web export only: narrows the baked glyph atlas to the languages you ship.
locales = ["en", "ja", "zh-Hans"]
That is the whole of an English, Japanese and Chinese game, on the desktop and
in the web export alike: naming the faces is what lets the exporter bake them
into the page, which is why a game that ships other scripts should name them
even when the developer’s own desktop already draws them. One thing to know
before you copy it: fallback_fonts replaces the whole chain, including the
face the engine ships for the box-drawing and mathematical characters, so put
that face back if your game draws any of them (see The fallback chain).
The rest of this page is what each key does, what you see when a character has no glyph anywhere, and how the web export differs.
Three faces are shipped, named by the job they do rather than by typeface:
Face |
Font |
Used for |
|---|---|---|
|
Noto Sans |
Everything drawn that is not code: labels, buttons, menus, dialogs |
|
Noto Sans Mono |
Fixed-pitch text, where columns must line up |
|
DejaVu Sans Mono |
Not drawn in directly: the last face the fallback chain asks |
The Noto faces are under the SIL Open Font License and DejaVu is under the Bitstream Vera licence. Ask for one by name:
from simvx.core.text import bundled_font_path
ui_font = bundled_font_path("ui")
code_font = bundled_font_path("mono")
Using a different font¶
One key in simvx.toml, and every string in the game is drawn in it:
[rendering]
font = "assets/fonts/Inter-Regular.ttf"
The path is relative to the directory holding simvx.toml, the same as every
other asset path in a project, and an absolute path works too. A .ttf or a
.ttc will do; a .ttc collection is loaded at its first face, so point the key
at a single-face file when the collection’s first face is not the one you want.
If the file is not there the engine says so in the log and
carries on with the bundled face, so a mistyped path costs you a typeface rather
than all your text.
To use whatever the player already has installed instead of the bundled face:
[rendering]
prefer_system_fonts = true
This is a preference, never an exclusion: if the search turns up nothing, the bundled font is still used, so no configuration can leave a game with no text.
You can also switch the face from code, which is what you want if the player picks the font in an options menu:
from simvx.graphics.draw2d import Draw2D
Draw2D.set_font("assets/fonts/Inter-Regular.ttf")
Call it whenever you like, including after the scene is built: widget auto-sizing is re-pointed at the new face in the same call, so every label, button and menu re-measures itself against the font it is now drawn in. Passing no arguments restores whatever the project settings resolve to.
Note
Every widget measures the text it holds against the font’s real glyph advances,
so a button is as wide as the string inside it. Do not size a widget by
multiplying a character count by a nominal advance: under a proportional face
like Noto Sans, i and W differ by nearly four to one. Use
simvx.core.text.measure_text_width() if you need a width yourself.
Warning
[rendering] mono_font is accepted by the project schema but is not yet read.
It cannot be, yet: Draw2D holds one glyph atlas at a time and draw_text takes
no face argument, so everything on screen is drawn in one face. See
Fixed-pitch text for what that means for a terminal or a code view.
Supporting other languages¶
Noto Sans holds 2965 characters: Latin down to Vietnamese and the IPA extensions, Greek, Cyrillic, the combining marks those need, and the punctuation, currency, arrows and letterlike symbols around them. No other script, and that is by design. Noto is a family of per-script faces plus a fallback chain, not one file that holds all of Unicode. Everything outside it – CJK, Arabic, Hebrew, Devanagari, Thai, the box-drawing block – comes from the fallback chain.
Note
Glyphs are laid out one per character, left to right, with kerning. There is no shaping and no bidi reordering, so a script that needs its letters joined or its run reversed (Arabic, Hebrew, Devanagari, Thai) draws its characters correctly but in isolated forms and in logical order. Latin, Greek, Cyrillic, Japanese, Chinese and Korean need neither and are unaffected.
The fallback chain¶
When the face a game draws in has no glyph for a character, SimVX looks through a chain of fallback fonts and the first that has the glyph supplies it. The glyph is packed into the same atlas, so a borrowed character costs no extra draw call.
Naming the chain yourself is the reliable option, and what you name is the whole chain, on the desktop and in the web bake alike: it replaces auto-detection outright, including the bundled floor described below. List every script you support.
[rendering]
fallback_fonts = [
"assets/fonts/NotoSansJP-Regular.ttf", # Japanese
"assets/fonts/NotoSansSC-Regular.ttf", # Simplified Chinese
"assets/fonts/NotoSansArabic-Regular.ttf", # Arabic
"assets/fonts/NotoSansHebrew-Regular.ttf", # Hebrew
]
The same list can be set from code, for a game that chooses its faces at runtime. This is also where to put the bundled floor back if you want the box-drawing and mathematical characters it carries, since the TOML list has no way to name a file inside the installed package:
from simvx.core.text import bundled_font_path
from simvx.graphics.text_renderer import get_shared_text_renderer
get_shared_text_renderer().set_font_fallbacks([
"assets/fonts/NotoSansJP-Regular.ttf",
"assets/fonts/NotoSansSC-Regular.ttf",
bundled_font_path("fallback"),
])
If you name no chain at all, one is assembled for you:
The bundled
"fallback"face, DejaVu Sans Mono. It is the floor, and it is there on every machine because the engine ships it: Arabic, Armenian, Georgian, Lao, all of the box-drawing, block-element, geometric-shape and APL blocks, and about seventy per cent of the mathematical operators. It comes first so that the characters it covers are drawn from it on every machine; several CJK fonts carry the box-drawing block too, and asking them first would draw a box-drawing character in whichever face the player happened to have.CJK fonts installed on the machine, which is why Japanese and Chinese usually render on a developer’s desktop with no configuration at all.
Nerd fonts installed on the machine, for the private-use icon glyphs (Powerline separators, git and file-type icons) that no standard typeface carries.
Only the first of those three is guaranteed. A game that leans on the other two draws correctly on the developer’s machine and shows boxes on a bare container, a stripped desktop, or a player’s machine that simply has no CJK font. Ship the fonts you need. Nothing in the chain goes looking for Hebrew, Devanagari or Thai on any machine, so those three are yours to ship whatever the developer desktop happens to have.
What happens without it¶
Nothing ever vanishes silently. A character that no font in the chain can draw is given the room it would have taken and a visible box is drawn in it, and the engine logs the character once:
No glyph available for U+05D0 HEBREW LETTER ALEF; drawing a missing-glyph box in its place.
The log line names the codepoint and its Unicode name, which is enough to identify which font you are missing. Once per character, not once per frame, so a screen full of unsupported text does not flood the log.
The box on screen carries the same information. At body size it is a plain
hollow box. At a size large enough to read, the codepoint is written out
inside the boxes, one box per byte, each holding that byte’s two hex digits.
05D0 draws as two boxes reading 05 and D0, so you can take the codepoint
off the screen and look it up without going near a log file.
Fixed-pitch text¶
Text that sits on a character grid, a terminal or a tabular readout, tells the layout so by passing the grid’s pitch:
renderer.draw_text(char, rect=(x, y, cell_w, cell_h), scale=scale,
alignment="centre", vertical_alignment="centre",
fit_to_width=True, cell_width=cell_w)
A character nothing can draw then occupies exactly one cell, scaled to fit, instead of taking the room a box asks for in proportional text. Every column after it stays where the grid puts it.
Take the pitch from the widest character the face draws, not from a digit:
from simvx.core.text import measure_text_width
printable = "".join(chr(code) for code in range(0x21, 0x7F))
cell_w = max(measure_text_width(char, font_size / 16.0) for char in printable)
A digit is only the right pitch for a face that draws every character on one
body, and the face your text is drawn in is not necessarily one: the engine’s
"ui" face is proportional, and a project can point the renderer at anything.
This is what TerminalEmulator.cell_size does, and why a terminal is legible
whatever it is drawn in.
Warning
Draw2D holds one glyph atlas at a time and draw_text takes no face argument,
so every widget draws in one face, including the terminal and code views.
Under a proportional face a character grid is correct but sparse: narrow
characters sit alone in a cell as wide as W. Under the bundled "ui" face that
is a real difference, not a rounding one. At the default 14px the terminal’s cell
is 13.1px wide against the 8.5px a fixed-pitch face gives it, so the same eighty
columns take half as much width again, and a panel that sizes its grid to its own
width fits about a third fewer columns. A terminal running a full-screen program
hands that column count to the process, so the program lays itself out for the
narrower grid. In a code view the same thing shows up as trailing comments at one
source column no longer lining up with each other.
If your game needs a tight monospace grid, draw the whole game in a fixed-pitch face:
from simvx.core.text import bundled_font_path
from simvx.graphics.draw2d import Draw2D
Draw2D.set_font(bundled_font_path("fallback")) # DejaVu Sans Mono
Use the "fallback" face, not "mono", if the grid has to be exact. Noto Sans
Mono gives every character the same advance in its outlines, but hinting rounds
those advances to whole pixels and does not round every one of them the same
way: at 48px its ASCII advances come out 28, 29 and 30 rather than a uniform
28.8, and the two-pixel spread is there at every size from 12px up, so a column
count multiplied by a pitch drifts from where its glyphs land. DejaVu Sans Mono
rasterises to a single advance at every size. A layout that places each character
at its own cell, which is what the terminal does, is unaffected either way,
because nothing accumulates.
The web export¶
Web export works differently, and the difference is worth knowing before it surprises you.
A browser cannot rasterise MSDF glyphs the way the desktop renderer does, so the
exporter bakes a glyph atlas ahead of time. It scans your game’s own source
strings and the translation catalogues it finds in your asset directories
(.json, .toml and .csv), and bakes exactly the characters it found.
Your own typeface on the web¶
Name the face and the exporter bakes it:
simvx export web game.py --font fonts/MyFont.ttf
or once, in your project’s simvx.toml:
[export.web]
font = "fonts/MyFont.ttf"
--font and export_web(font=...) read the path against your game file’s
directory; [export.web] font reads it against the directory holding
simvx.toml, the way that file writes every other path. When the two are the
same directory, so is the path.
A path that names no file refuses the export: the page has no second face to fall
back on, so a face asked for and quietly not baked is a page shipped in the wrong
typeface with nothing to say why. Name the .ttf or .otf, not a .woff2: the
bake runs here on the host, through FreeType, which usually cannot read the
compressed web format. export_web(font=...) and --font take
precedence over the project key, and both take precedence over the [rendering] font your game runs in on the desktop, so a game can run in one face and export
in another.
The font file itself does not travel with the page. The atlas is the delivery
mechanism: the engine rasterises no face in the browser, so a face sitting in the
bundle cannot become the typeface your text is drawn in, and base64 would charge a
third over its own size to carry it there. A 2.6 MB face used to add 3.5 MB to a
page whose text pipeline could not use a byte of it. So a face left in a fonts/
directory beside the game, or inside a directory you passed to --asset, is
dropped. The export reports every file it dropped and names the face it baked in
their place; if nothing named a face at all, so the page is set in the fallback
rather than the one you shipped, it says so as a warning.
Naming a font file by itself as an asset (--asset fonts/MyFace.woff2) ships
it anyway. That is not a way to change the game’s typeface, which is settled by
the bake; it is for a page that loads the face through the browser’s own font
machinery and draws the unbaked characters in it, which is what
app.set_dynamic_glyph_font(...) further down is for.
One face per page: the atlas holds a single typeface, so this is the face the whole game is set in.
A character the game’s own face has no glyph for is baked from the part of the
fallback chain that is the same on every machine: the faces you named in
[rendering] fallback_fonts, or, if you named none, the bundled "fallback"
face. Both keys are read from your game’s simvx.toml, not from wherever
the export command was run, so the page bakes the faces the game runs in
whichever directory you build from. Borrowed glyphs go into the same atlas at
the same pixel size, so the page draws them exactly as the desktop does. This is
why the box-drawing and block-element blocks and most of the mathematical
operators survive the export with no configuration at all.
The rest of the chain, the CJK and nerd fonts found by searching the machine, is
deliberately not baked: it would put whatever the exporting developer had
installed into the page and leave the next developer’s build without it. So a
game that draws Japanese on a developer’s desktop with no configuration at all
bakes no Japanese, and naming the face in fallback_fonts is what fixes that.
It is the one case where the desktop is more forgiving than the export, so test
the exported page rather than trusting the desktop run.
Anything it could not predict, text typed by the player, text fetched at runtime, is rasterised in the browser at runtime from the fonts the player’s machine has. It draws, but in whichever face the browser picks rather than in your game’s typeface.
The exporter tells you what it could not bake:
11 character(s) have no glyph in the baked font (ときこんにちは世界です): the
page rasterises them from the browser's own fonts at runtime, so they draw in
whichever face the player's browser picks rather than the game's typeface.
By default the page asks the browser for "Noto Sans", sans-serif, so a machine
that has Noto Sans lays unbaked text out at the same advances the atlas was
baked at. If the page can load your own face, name it so that unbaked characters
are drawn in your typeface too:
app.set_dynamic_glyph_font('"MyGameFont", sans-serif')
Call it before the first frame. The family has to be one the page can load, and
registering it is yours to do: bundle the face by naming its file on its own
(--asset fonts/MyFace.woff2, the one way a font file does travel with a page),
then register it with the browser’s own CSS Font Loading API (js.FontFace plus
document.fonts.add) before the first frame. The engine does not do this for you:
a bundled face is the browser’s to read, not the engine’s, and it only ever draws
the characters the atlas had no glyph for. The rest of your text is still set in
the baked face, so bake the same one (--font) if you want the two to match.
Narrowing a large catalogue¶
A game with a twenty-language translation catalogue bakes every glyph of all twenty by default, which is a large atlas for a game that ships three. Name the languages you actually ship:
[rendering]
locales = ["en", "ja", "zh-Hans"]
The exporter then reads only those columns of your catalogues. en is always
included as a fallback, so a key missing a translation still bakes. Codes are
matched as the catalogues write them: en, fr_CA, zh-Hans all work.
You can pass the same list to the export API directly:
from simvx.web.export import export_web
export_web("game.py", "game.html", locales=["en", "ja", "zh-Hans"])
See also¶
UI System – the widgets this text is drawn in
Web Export – the rest of the web export pipeline
Package Resources – how bundled assets are resolved