This tutorial covers the two smallest modules in OpenSNES: gameloop (one public function) and asset (two public functions, two macros). They are the first pieces a newcomer meets — the main() and the "get my PNG on screen" step — and both are deliberately optional.
PHILOSOPHY.md lists "Mandatory framework lifecycle" under Non-goals, in these words:
A user is welcome to write a main() with their own while(1) { WaitForVBlank(); ... } loop. The framework opt-ins are recommendations, not requirements.
That is not a disclaimer, it is the design. Both modules are linked only if you name them in LIB_MODULES (Principle 3 — modules are opt-in, never all-or-nothing):
asset transitively pulls dma and background (_DEP_asset in make/common.mk). gameloop has no _DEP_ line at all — it pulls nothing beyond the runtime that every ROM already links.
The corpus votes with its feet: three examples call gameLoopRun (basics/timer, basics/aim_target, input/controller); three use the asset bundles (backgrounds/mode1, backgrounds/mode1_bg3_priority, games/shmup_1942). Everything else writes the loop and the loads by hand, and nothing about those examples is second-class. games/shmup_1942 uses bgLoad and hand-writes its loop — the two opt-ins are independent of each other.
The third member of this family is the scene stack (sceneRun / scenePush / scenePop), which has its own page — see Game States Tutorial for that one.
GameLoopConfig is a pair of function pointers. It is not its own struct: it is a typedef of Scene from <snes/scene.h>, so the same value can be handed to gameLoopRun today and to sceneRun tomorrow without touching the declaration.
The whole implementation (lib/source/gameloop.c) is five lines:
So the call site becomes:
cfg may live on main()'s stack, as all four examples do, precisely because main never returns while the loop runs. The return 0 exists only to keep int main(void) well-formed; cproc/QBE has no noreturn path, so the compiler cannot know the call never comes back.
There is no NULL check on cfg->update. That is the same deliberate omission documented on Scene::update — a JSL into the register page crashes immediately and is more diagnosable than a silently skipped frame, and the per-frame test is not free at 1.79 MHz.
This is worth stating precisely, because the framework's share of it is smaller than it looks:
| # | What runs | Who owns it |
|---|---|---|
| 1 | init(), once, before any WaitForVBlank | your code |
| 2 | WaitForVBlank() — sets vblank_flag = 1, then WAI | templates/crt0.asm |
| 3 | NMI fires: frame_count++, dynamic-sprite flush hook, OAM DMA, tilemap DMA, BG scroll registers, joypad/mouse/scope read | the NMI handler in crt0.asm |
| 4 | NMI clears vblank_flag, WaitForVBlank returns | crt0.asm |
| 5 | update() | your code |
gameLoopRun itself contributes steps 2 and 5 and nothing else. Everything in step 3 — including the input read that populates padHeld / padPressed — happens in the NMI handler and happens identically for a hand-written loop. The framework does not read input, does not draw, and does not flush anything.
One consequence deserves emphasis: update() does not run inside a usable VBlank window. WaitForVBlank returns only after the NMI handler has run to completion and cleared the handshake flag (crt0.asm, the stz.w vblank_flag at the very end of the ISR), and the handler has already spent the VBlank on its own DMAs. By the time update() starts, VBlank is largely or entirely gone. Write to the shadow buffers the lib gives you (the text tilemap buffer, oamMemory, the scroll shadows) and let the next NMI upload them — which is what the examples do. Do not treat update() as a place where direct VRAM writes are safe.
The Doxygen on examples/basics/timer's on_update currently claims the opposite ("we are inside the VBlank window — VRAM/CGRAM/OAM writes are safe at this point"). That comment is wrong; the example itself is fine because textPrint only touches a RAM buffer. Trust this table, not that comment.
It does not call consoleInit(), does not setMode(), does not touch palettes, does not setScreenOn(), and does not install or configure the NMI handler. All of that stays in your init callback, which is why any existing hand-written main() converts by moving two blocks of code into two static functions and changing nothing else. The canonical init body is the same one you would have written inline:
Cost: one indirect call plus one direct call per frame, on the order of 30 cycles — the indirect call through cfg is not tail-call-optimised.
A background needs three blobs (tiles, palette, tilemap), two numbers describing them (colour depth, map size) and two VRAM addresses. Written by hand that is six extern declarations, three subtractions and three separate calls, all positional. GfxAsset and BgAsset bundle the first five into one value:
The struct stores begin/end pairs rather than sizes on purpose: the difference of two extern symbols is not a compile-time constant in C, so a size field could not be initialised in static const storage. The subtraction is done at load time instead.
Because the bundle is a value, it can be passed to a function, or stored in an array indexed at runtime as a level table, or swapped at a scene boundary. The addresses stay at the call site — the same tileset can be loaded to a different VRAM address on a different screen.
lib/source/asset.c composes three existing calls, in this order:
and gfxLoad is the bgInitTileSet call you would otherwise write out:
So one bgLoad fans out into three transfers and two register writes: tiles to VRAM at tiles_vram, palette to CGRAM at a slot-derived colour index, tilemap to VRAM at map_vram, plus BGnSC (map address + size) and BGnNBA (tile address).
That fan-out is pinned by a functional test rather than by prose. testing/manifests/dma_mode1_bgasset.toml runs examples/backgrounds/mode1 — whose entire body is bgLoad(0, &bg, 0, 0x4000, 0x0000) — and asserts all three destinations independently: bytes in CGRAM at colour 0, bytes in VRAM under the tilemap at word $0000, and bytes in VRAM under the tiles at word $4000 (byte $8000; VRAM byte address is word address × 2). The manifest also asserts unsafe_writes = 0, i.e. every one of those transfers happened under forced blank. A field-order slip in BgAsset — swapped tiles/map pointers, a stale _end label — fails a named block instead of quietly changing the picture.
palette_slot is an index, not a CGRAM address. bgInitTileSet converts it according to color_mode, and the conversion is not uniform:
| color_mode | CGRAM colour index |
|---|---|
| BG_16COLORS | slot * 16 |
| BG_4COLORS | slot * 4 |
| BG_4COLORS0 (Mode 0) | bg * 32 + slot * 4 — the BG number participates |
| BG_256COLORS | always 0 — palette_slot is ignored |
backgrounds/mode1_bg3_priority is the worked example: three BG_16COLORS bundles at slots 2, 4 and 0, which land at CGRAM colours 32-47, 64-79 and 0-15 respectively.
bgLoad and gfxLoad DMA into VRAM and CGRAM with no VBlank gating of their own. They are setup calls, not per-frame calls. Two idioms in the corpus, both correct:
Prefer the explicit form in new code; it survives being moved into a scene's init, where the screen is already on.
DECLARE_BG_ASSET(name, color_mode, map_size) expands to six extern declarations plus a static const BgAsset name initialised from them. It requires the symbol naming convention <name>_tiles, <name>_pal, <name>_map, each with an _end sibling. DECLARE_GFX_ASSET(name, color_mode) is the same without the map pair.
You do not write those symbols yourself. The blobs come out of opensnes-tileset, driven by a settings file beside the picture — backgrounds/mode1 has res/opensnes.png.toml:
make/common.mk runs the converter before the first C object and links the res/opensnes_data.as it writes (one ASSET_SECTION per blob — tiles, map, palette — so nothing lands in bank $00). The generated res/opensnes.inc is the entire contract between the converter and the macro: the six extern declarations in that naming, and the DECLARE_BG_ASSET itself, so the C side is one line:
A picture converted with lz = true, or one whose tileset is cut in 32 KB parts, gets the plain extern declarations instead of a bundle, with a comment saying why (bgLoad() would copy compressed or partial bytes).
The macro is sugar; the struct is the contract. Nothing stops you from filling a BgAsset yourself when the symbols do not follow the convention, or when the pointers are computed rather than linked.
ASSET_SECTION (templates/assets.inc, included in every assembled file) is the way to declare asset data: SEMISUPERFREE BANKS from the last bank down to 1 (ASSET_BANKS, ROM_BANKS - 1 to 1 since 2026-09-24), so bank $00 is never a candidate. A bare superfree section lets the linker pick the first bank that fits — bank $00 — which is how 14 examples ended up within 28 bytes of a full code bank before the corpus moved over on 2026-09-23.
The honest measure of a convenience is how much it removes. Here is backgrounds/mode0, which loads four layers manually, against the same shape written with bundles.
Manual — examples/backgrounds/mode0/main.c, one layer's worth:
Bundled:
Three externs and three calls become one declaration and one call. The saving compounds: mode0 repeats that block four times, so the layer-loading body goes from twelve statements to four. What actually reaches the hardware is identical — same bgInitTileSet, same dmaCopyVram, same bgSetMapPtr, in a slightly different order.
The same comparison for the loop, examples/basics/timer against its hand-written form:
That is the whole delta — two lines of structure. The framework's value here is not line count, it is that the shape of an SNES program (init once, then one body per VBlank) is named in the API instead of being folklore a beginner has to infer from an example.
Reach for the hand-written version when: