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

"ui"

Noto Sans

Everything drawn that is not code: labels, buttons, menus, dialogs

"mono"

Noto Sans Mono

Fixed-pitch text, where columns must line up

"fallback"

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:

  1. 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.

  2. CJK fonts installed on the machine, which is why Japanese and Chinese usually render on a developer’s desktop with no configuration at all.

  3. 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