This tutorial covers the text module — an 8×8 tile font, a 32×32 tilemap buffer and a cursor, which together are how most OpenSNES examples put words on screen. Add text to LIB_MODULES (it pulls in dma, background and console); add text4bpp as well if you want the 4bpp font.
The SNES has no text hardware. A glyph is a tile, a line of text is a row of tilemap entries, and "printing" is writing those entries. The module owns that bookkeeping — and, importantly, owns when the tilemap reaches VRAM, which is the part that surprises newcomers.
Everything text* writes goes into one 2 KB buffer in WRAM:
tilemapBuffer is 32×32 entries of 2 bytes = 2048 bytes, and it lives in the bank $00 WRAM mirror below $2000 (the compiler addresses C arrays there — see Far RAM Tutorial — FAR objects beyond the 8 KB band for why that band matters).
Every writer in the module — textPutChar, textPrint, textPrintAt, textPrintU16, textPrintHex, textClear, textClearRect — sets tilemap_update_flag on its way out. The NMI handler checks that flag at the top of each VBlank and, if set, DMAs the whole buffer to VRAM (step 2 of its execution order, right after the OAM DMA). Nothing you print is visible until that VBlank happens.
Coming from PVSnesLib? consoleDrawText wrote to a console buffer you then flushed. Here the flush is automatic: there is no call to forget. The counterpart, textFlush(), is documented as rarely needed for exactly that reason — it only sets the flag:
Reach for it only when you patched tilemapBuffer by hand, outside the text* API. Calling it after a regular textPrint is a no-op.
textModeInit() is the whole setup for a text-first program. Its Doxygen spells out exactly what it is equivalent to:
So it assumes: Mode 0, text on BG1, the 2bpp font at VRAM word $0000, the tilemap at VRAM word $3800, palette slot 0, backdrop black and colour 1 white. That is all of Print a string —
Two consequences of that equivalence list are worth internalising, because they bite in every example that does more than print:
It puts the font at VRAM $0000, so your sprite or BG tiles must go somewhere else. game_skeleton and aim_target both park sprite graphics at $4000 for this reason:
When text is a HUD over a game rather than the whole program, do the two steps yourself:
textInit also resets the cursor to (0, 0), fills the buffer with the space glyph and performs an immediate DMA to VRAM — not a flagged one. That is a raw VRAM write, so like textLoadFont, it belongs in forced blank, before setScreenOn().
Wiring the BG layer is still yours. rpg — an RPG template driven by a Tiled map puts 2bpp text on BG3 in Mode 1, in front of the map:
Note text_config.priority — the TextConfig struct is public (extern TextConfig text_config), and its priority field goes into bit 13 of every tilemap entry the module builds from then on. Setting it right after textInit is the supported way to put text in front of an opaque background.
Dynamic Map shows the 4bpp case, and the font_tile arithmetic that goes with a font not placed at the layer's tile base:
Get that offset wrong and you get glyphs from elsewhere in VRAM — legible garbage, which is the confusing kind.
textPrint is a textPutChar loop; textPrintAt is textSetPos followed by textPrint. So all the behaviour lives in textPutChar:
| Input | What happens |
|---|---|
| '\n' | column → 0, row → row + 1 |
| '\r' | column → 0, row unchanged |
| ASCII 32–127 | glyph written at the cursor, column → column + 1 |
| anything else | the space glyph is written (font_tile), column advances |
Two wraps, and both are guards rather than errors:
textSetPos masks rather than clamps: x & 31 and y & 31. An x of 40 becomes column 8, not column 31 and not an error.
One geometry note: the buffer is 32 rows but a standard 224-line display shows only 28 of them (224 / 8). Rows 28–31 exist, are flushed, and are simply off-screen until you scroll the layer — which is exactly what Scroll a message does with bgSetScroll.
textPrintU16 prints 1–5 decimal digits at the cursor: most-significant first, no leading zeros, no padding, and a bare 0 for zero. textPrintHex prints digits uppercase hex digits (clamped to 4 internally), with no 0x prefix — aim_target adds its own:
Because neither function pads, a shrinking number smears: print 100 then 99 and you are looking at 990. The idiom across the corpus is to blank the field first, as game_skeleton does:
textClear() fills the whole 32×32 buffer with the space glyph via an assembly fill loop (~14 000 cycles, against ~238 000 for the compiled-C equivalent it replaced). It does not move the cursor — set the position yourself afterwards.
textClearRect() blanks a rectangle and clamps hard: a start position outside the buffer returns immediately, and a width or height that would run off the edge is truncated to fit. You cannot make it write outside the buffer.
Scene Stack shows the typical rhythm — textClear() when entering a scene, then the static labels, then per-frame updates into fixed fields.
The flush sends the bytes written since the last VBlank: every text* writer records the first and the last byte it touched in tilemapBuffer, and the NMI handler DMAs that range — one glyph costs 2 bytes, a counter that redraws one row costs that row, textClear() costs the whole 2048. (Until 2026-10-12 the flush was all-or-nothing: 2048 bytes, 16,384 master cycles of the VBlank, for one glyph — against the roughly 4 KB of practical VBlank DMA budget of DMA Tutorial, half the frame's transfer capacity.) The range is one span: text written at the top and at the bottom of the screen in the same frame sends everything in between too, which is still the content the rows hold.
Two corollaries:
Don't reformat text you did not change. The formatting loops themselves are not cheap either. aim_target learned this the measured way:
If your HUD only changes on a score or timer tick, guard it with a dirty flag and the text DMA disappears from most frames entirely. The frame budget puts this in the context of the rest of the frame.
The module ships with a built-in font, so the common case needs no asset at all. It is 96 glyphs covering ASCII 32–127, in tile order (index = ASCII − 32), and it lives in ROM as opensnes_font_2bpp in a SUPERFREE section:
| Call | Format | Size | Module |
|---|---|---|---|
| textLoadFont(addr) | 2bpp, 16 bytes/tile | 1536 bytes | text |
| textLoadFont4bpp(addr) | 4bpp, 32 bytes/tile, bitplanes 2–3 zero | 3072 bytes | text4bpp |
Use the 4bpp variant when the text layer is a 4bpp BG — BG1 or BG2 in Mode 1 — and remember to add text4bpp to LIB_MODULES, or the link fails on textLoadFont4bpp. The 4bpp font is a separate module precisely so the 3 KB does not land in every ROM.
Both loaders write VRAM directly through DMA, so call them during forced blank, before setScreenOn().
The built-in face is effectively 1-bit: bitplane 1 of every tile is zero. Glyph pixels are therefore colour index 1 of the chosen palette slot, and colour 0 is the transparent background. To colour your text, write colour 1 of its slot:
| Depth | Colours per slot | Text colour for slot P | Example |
|---|---|---|---|
| 2bpp | 4 | setColor(P * 4 + 1, c) | rpg, slot 4 → setColor(17, …) |
| 4bpp | 16 | setColor(P * 16 + 1, c) | dynamic_map, slot 1 → setColor(17, …) |
textModeInit() does the 2bpp slot-0 case for you: setColor(1, RGB(31, 31, 31)), white on a black backdrop.
A custom font is just different tiles at the same VRAM address. Draw the 96 glyphs (ASCII 32–127, space first) in 8x8 cells, put res/myfont.png.toml beside the picture with tool = "opensnes-text" and a [font] table (opensnes-text — bitmap fonts for the text module; the 0.x font2snes is font2snes — font images to text tiles), and the build writes the tiles and res/myfont.inc. Upload them yourself and point textInit at them:
No example needs a custom font, because textLoadFont already gives you a usable face.
The code guards the ones it can: the cursor wraps instead of overflowing the buffer, textClearRect clamps to the edges, textPrintHex clamps digits to 4, textSetPos masks its arguments, and a character outside ASCII 32–127 becomes a space rather than a wild tile index. These are the ones left to you.
Both write VRAM directly — textLoadFont sets VMADD and fires a DMA, textInit ends with an immediate tilemap flush. Outside forced blank or VBlank the PPU silently drops those writes and you get a screen of garbage tiles with no error anywhere. Init first, setScreenOn() last.
textModeInit loads the font at word $0000, occupying 1536 bytes. Anything else you upload there overwrites glyphs — usually visible as a few letters turning into fragments of your sprite. Put other graphics at $4000 (or anywhere clear) as the examples do.
Printing sets a flag; the NMI does the work. A program that prints and then spins without ever reaching a VBlank shows nothing. The canonical opening is WaitForVBlank() then setScreenOn(), then a loop whose first statement is WaitForVBlank().
Its last act is setMainScreen(LAYER_BG1). Re-call setMainScreen with LAYER_BG1 | LAYER_OBJ after it, not before.
textPrintU16 and textPrintHex do not pad. Blank the field before rewriting it (see the draw_score idiom above).
tilemap_addr has been a VRAM word address since v0.23.0. Ported code that passes 0x7000 for a tilemap at word $3800 puts the DMA target 16 KB past where the BG reads its map — a blank layer, no diagnostic.
TextConfig has a map_width field documented as 32 or 64, but textInit always sets it to 32, the buffer is sized for 32×32, and the NMI flush sends the buffer's bytes. Treat map_width as read-only; a 64-wide text layer is not something the module supports today.