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 — effectively dividing by 256 while preserving the sign. The compiler's>>operator uses logical shift (LSR), which fills the top bit with 0 instead of the sign bit. For negative velocities (moving left), this gives the wrong answer.asr8()is a workaround that inverts, shifts, and inverts back.
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.
mario_y + 16 overshoots by even one pixel, the tile lookup returns the row below the floor — empty space — and Mario keeps falling.tile_x + 32, not tile_x + 31. The 33rd tile is partially visible.bgSetScroll() must happen during VBlank. If it's called during active display, the top and bottom halves of the screen scroll by different amounts for one frame.tile_props[]. When Mario touches one, replace the tilemap entry with an empty tile and increment a counter.mariojump.brr (a jump sound effect) and overworld.it (music). Wire them up with SNESMOD — see SNESMOD Music for the pattern.GRAVITY, MARIO_ACCEL, and MARIO_JUMPING. Small tweaks completely change how the game feels. This is how real platformers are tuned.The Makefile includes custom rules for two different asset types:
| Flag | Meaning |
|---|---|
-s 8 / -s 16 | Tile size (8x8 for backgrounds, 16x16 for sprites) |
-o 16 | Palette offset: start at color 16 in CGRAM |
-u 16 | Use up to 16 unique colors |
-p | Generate palette file (.pal) |
-m | Generate tilemap file (.map) — only for backgrounds |
-i | Input file (PNG format, the default) |
| 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() |
data.asm uses two different section types for a reason:
The map data is in SEMIFREE BANK 0 because the compiler generates lda.l $0000,x for array accesses — which always reads from bank $00. If the map were in bank $01, every tile lookup would return garbage.
| 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) |
data.asm | ROM assets: tiles, sprites, palettes, 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 |