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.
Text baked into an image¶
2D text turns with its node, so a label under a spinning parent spins with it.
When the text is part of an image, a card’s rank or a painted sign, bake it into
that image instead, where it costs nothing extra to draw. Draw2D.text_pixels rasterises a
line in the font 2D text uses, on desktop and on web, into an RGBA uint8 array
you can composite into an image or hand to a Sprite2D:
from simvx.graphics.draw2d import Draw2D
rank = Draw2D.text_pixels("K", scale=3.0, colour=(0.1, 0.1, 0.1, 1.0))
scale is the multiplier draw_text takes (scale=1 is 16 px). The image keeps
a 2 px margin, so its pen origin, the left end of the baseline, is pixel
(2, 2 + Draw2D.text_ascent(scale)); that is how to set text on a baseline. It
draws one line, and raises when no font is loaded rather than returning a blank
image. In the browser it can only draw glyphs the export baked, so a character
outside them is boxed; bake the languages you ship with [rendering] locales.
The web export¶
A web export bakes the glyphs it needs into an atlas at export time, from your source strings and
translation catalogues, in one face. Name every face you need in fallback_fonts: system fonts the
desktop finds are not baked, so test the exported page rather than trusting the desktop run.
[rendering] locales narrows the atlas to the languages you ship. See
the web export’s font atlas.
Characters the atlas lacks, such as text the player types, are rasterised in the browser from the
player’s fonts. app.set_dynamic_glyph_font('"MyGameFont", sans-serif'), called before the first
frame, names the CSS family to draw them in; registering that face with the browser is yours to do.
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