A side-scrolling platformer with gravity, tile collision, and a camera that follows the player across a world bigger than the screen. This is the real deal.
| Button | Action |
|---|---|
| D-Pad Left/Right | Walk |
| A | Jump (hold Up + A for higher jump) |
Then open likemario.sfc in your emulator (Mesen2 recommended).
The SNES screen is 256 pixels wide — 32 tiles. But the LikeMario map is hundreds of tiles wide. The PPU's tilemap is only 32x32 entries, so you can't load the entire world at once. Instead, the game streams new tile columns into VRAM as the camera scrolls.
Think of the tilemap as a circular buffer. As the camera moves right, the leftmost column scrolls off-screen. That column's VRAM slot gets overwritten with the next column from the map. The player never sees the swap because it happens off-screen.
The tileset (the pixel art for every unique tile) lives at $2000. The tilemap (which tile goes where) lives at $6800. Sprites get $0000-$1FFF. This layout avoids overlaps and leaves room for the 32x32 tilemap to wrap around.
Streaming happens in three stages, split across the frame:
During active display (CPU is free, PPU is drawing):
During VBlank (PPU is idle, VRAM is writable):
The decision logic (every frame):
Why +32, not +31? The screen shows tiles 0-31, but tile 32 is partially visible at the right edge (the camera rarely sits on an exact tile boundary). Without that extra column, you'd see a blank strip flickering at the right side during scrolling. This was a bug in the original port that took a while to track down.
The 65816 has no multiply instruction and runs at 3.58 MHz. You can't afford floating-point anything. Instead, positions and velocities use 8-bit fixed-point: the high byte is the pixel position, the low byte is the fractional part (1/256th of a pixel).
At 60fps, a velocity of 0x0140 (= 1.25 pixels/frame) moves Mario at 75 pixels per second. The fractional accumulator means Mario can move at speeds finer than 1 pixel per frame — essential for smooth deceleration.
What's asr8()? Arithmetic shift right by 8 — dividing by 256 while preserving the sign, written out with an unsigned shift and two inversions so the intent is visible. cc65816's >> on a signed value IS arithmetic (the compiler checks pin it: devtools/compiler-tests/cases/test_signed_ops.c), so val >> 8 would do the same; the helper is readability, not a workaround. (Until 2026-10-04 this box claimed the compiler shifted logically.)
GRAVITY = 48 (in 1/256 pixel/frame units). That's roughly 0.19 pixels/frame of acceleration — feels like a Mario game because these constants were tuned by PVSnesLib to match the original's feel.
Jumping sets a negative Y velocity:
No bounding boxes, no sprite lists. The world is made of tiles, and collision is a lookup:
map_row_ptrs[] is precomputed at load time — one pointer per row, so looking up a tile is just an array index. No multiplication, no searching. O(1).
To check if Mario is standing on ground, test two points at his feet:
Two points, not one, because Mario is 16 pixels wide. Testing only the center would let him hang off ledges with half his body in mid-air.
One frame, one iteration. Game logic runs during active display, VRAM writes during VBlank:
The split is strict: everything above WaitForVBlank() touches only RAM. Everything below it writes to hardware. Break this rule and you get visual corruption.
The Makefile has no conversion rule: each picture carries its import settings in a file beside it, and the build runs the right tool before compiling.
The tools write the .pic / .pal (and the .map for the tileset), a res/<name>_data.as the build links, and a res/<name>.inc that declares <name>_tiles[], <name>_pal[] (each with _end) — main.c includes it.
| Module | Why it's here |
|---|---|
| console | PPU init, NMI handler, WaitForVBlank() |
| sprite | OAM buffer for Mario |
| sprite_dynamic | VRAM queue for animated sprite frames |
| sprite_lut | Tile offset calculations for the dynamic engine |
| dma | Bulk transfers: tile data, palettes, OAM buffer |
| input | padHeld(), padPressed() — NMI handler fills the buffers |
| background | bgSetScroll(), bgSetGfxPtr(), bgInitTileSet() |
Every asset sits in an ASSET_SECTION (templates/assets.inc): the linker puts it in the asset banks, never in bank $00, which is kept for code. The converted graphics get theirs from the generated res/<name>_data.as; the map and collision data, which no tool converts, keep a hand-written one in data.asm:
The map and collision data are read by C too, and that works from any bank: the C side declares them const, and every C read of const data is a far read (since v0.41.0). Until 2026-09-23 they sat in SEMIFREE BANK 0 "because the compiler reads bank $00" — true before far pointers, and what this page said until 2026-09-26.
console, sprite, sprite_dynamic, sprite_lut, dma, input, background, anim (LIB_MODULES in the Makefile).
| Register | Address | Role in this example |
|---|---|---|
| BGMODE | $2105 | Mode 1 (BG1 4bpp for the world) |
| BG1SC | $2107 | BG1 tilemap at $6800 |
| BG12NBA | $210B | BG1 tiles at $2000 |
| BG1HOFS | $210D | Horizontal scroll (= camera_x) |
| VMAIN | $2115 | VRAM increment mode ($81 = vertical for column writes) |
| TM | $212C | Enable OBJ + BG1 |
| INIDISP | $2100 | Force blank during init |
| File | What's in it |
|---|---|
| main.c | All game logic: physics, collision, streaming, camera (~493 lines) |
| res/*.png.toml | the import settings of the tileset and the sprite sheet; the build converts and links them |
| data.asm | ROM assets no tool converts: map data, collision table |
| res/tiles.png | Background tileset source (8x8 tiles) |
| res/mario_sprite.png | Sprite sheet source (16x16 frames) |
| res/BG1.m16 | World tilemap (tile indices + flip/palette bits) |
| res/map_1_1.b16 | Per-tile collision properties (solid/empty) |
| res/overworld.it | Music file (Impulse Tracker, not yet wired up) |
| res/mariojump.brr | Jump sound effect (BRR, not yet wired up) |
| Makefile | LIB_MODULES := console sprite sprite_dynamic sprite_lut dma input background |