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.
Controls
| Button | Action |
| D-Pad Left/Right | Walk |
| A | Jump (hold Up + A for higher jump) |
Build & Run
cd $OPENSNES_HOME
make -C examples/games/likemario
Then open likemario.sfc in your emulator (Mesen2 recommended).
What You'll Learn
- How tile streaming works — loading new map columns into VRAM as the camera scrolls
- Fixed-point physics: gravity, acceleration, and sub-pixel movement on a CPU with no multiply
- Tile-based collision detection with O(1) lookups (no sprite-to-sprite checks)
- The double-buffer pattern: stage data in RAM during active display, flush to VRAM during VBlank
- Why the camera position and the scroll register are two different things
Walkthrough
1. The World Is Bigger Than the Screen
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.
2. The VRAM Layout
#define VRAM_SPR_LARGE 0x0000
#define VRAM_SPR_SMALL 0x1000
#define VRAM_BG_TILES 0x2000
#define VRAM_BG_MAP 0x6800
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.
3. Streaming: The Column Pipeline
Streaming happens in three stages, split across the frame:
During active display (CPU is free, PPU is drawing):
}
}
static u16 col_buffer[32]
Column tile buffer for deferred VRAM streaming.
Definition main.c:193
static u16 map_height
Definition main.c:138
static void map_prepare_column(u16 map_col, u16 vram_col)
Buffer a map column for deferred VRAM write (prepare phase).
Definition main.c:317
static u16 * map_row_ptrs[32]
Precomputed row pointers into map_data for fast tile lookup.
Definition main.c:149
static u8 col_pending
Definition main.c:195
unsigned short u16
16-bit unsigned integer (0 to 65535)
Definition types.h:53
During VBlank (PPU is idle, VRAM is writable):
}
static void map_flush_column(void)
Flush the buffered column to VRAM via DMA (flush phase).
Definition main.c:345
#define REG_VMAIN
VRAM address increment mode (W).
Definition registers.h:112
The decision logic (every frame):
new_col = tile_x + 32;
}
}
static s16 last_tile_x
Definition main.c:152
static s16 camera_x
Definition main.c:151
static void map_update(void)
Check if the camera has scrolled to a new tile column and prepare it.
Definition main.c:377
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.
4. Fixed-Point Physics
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).
static s16 mario_x
Definition main.c:154
static s16 mario_xvel
Definition main.c:158
static s16 asr8(s16 val)
Arithmetic right shift by 8 bits (extract integer part of 8.8 fixed-point).
Definition main.c:493
static u8 mario_xfrac
Definition main.c:156
signed short s16
16-bit signed integer (-32768 to 32767)
Definition types.h:50
unsigned char u8
8-bit unsigned integer (0 to 255)
Definition types.h:47
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.
5. Gravity in Two Lines
static s16 mario_yvel
Definition main.c:159
#define GRAVITY
Gravity acceleration in 8.8 fixed-point units per frame.
Definition main.c:100
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:
else
}
#define MARIO_HIJUMPING
High jump initial upward velocity (when holding UP during jump).
Definition main.c:108
#define MARIO_JUMPING
Normal jump initial upward velocity in 8.8 fixed-point.
Definition main.c:106
6. Tile-Based Collision
No bounding boxes, no sprite lists. The world is made of tiles, and collision is a lookup:
}
static u8 px[64]
Definition main.c:51
static u8 py
Definition main.c:73
static u16 map_get_tile_prop(s16 px, s16 py)
Look up the collision property of the tile at pixel coordinates.
Definition main.c:214
static u16 * tile_props
Definition main.c:140
static u16 map_width
Definition main.c:137
#define T_SOLID
Solid tile (blocks movement).
Definition map.h:57
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:
}
static u8 mario_action
Definition main.c:160
static s16 mario_y
Definition main.c:155
#define ACT_STAND
Definition map.h:93
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.
7. The Main Loop
One frame, one iteration. Game logic runs during active display, VRAM writes during VBlank:
while (1) {
}
void bgSetScroll(u8 bg, u16 x, u16 y)
Set background scroll position.
void WaitForVBlank(void)
Wait for next VBlank period.
static void mario_update_camera(void)
Update camera to follow Mario and position the sprite on screen.
Definition main.c:660
static void mario_collide_vertical(void)
Check vertical tile collisions (ground landing and ceiling bonk).
Definition main.c:533
static void mario_apply_physics(void)
Apply gravity and integrate velocity into position.
Definition main.c:507
static void mario_animate(void)
Update Mario's sprite animation frame based on current action.
Definition main.c:642
static void mario_collide_horizontal(void)
Check horizontal tile collisions (wall sliding).
Definition main.c:574
static void mario_clamp_and_transition(void)
Clamp Mario to world boundaries and handle action state transitions.
Definition main.c:607
static void mario_handle_input(void)
Process D-pad and button input for Mario's movement.
Definition main.c:438
The split is strict: everything above WaitForVBlank() touches only RAM. Everything below it writes to hardware. Break this rule and you get visual corruption.
Tips & Tricks
- Mario falls through the floor? Check your collision points. If mario_y + 16 overshoots by even one pixel, the tile lookup returns the row below the floor — empty space — and Mario keeps falling.
- Tiles missing at the right edge? Your streaming offset is wrong. It must be tile_x + 32, not tile_x + 31. The 33rd tile is partially visible.
- Mario slides after stopping? That's intentional — deceleration. The velocity decreases by a fixed amount each frame until it reaches zero. Remove the deceleration code and Mario stops instantly (feels stiff).
- Scrolling tears or flickers? 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.
Go Further
- Add enemies: Use a second sprite with its own collision box. Check overlap with Mario each frame. Start simple — a Goomba that walks left until it hits a wall.
- Add coins: Mark certain tiles as collectible in tile_props[]. When Mario touches one, replace the tilemap entry with an empty tile and increment a counter.
- Add sound: The assets include mariojump.brr (a jump sound effect) and overworld.it (music). Wire them up with SNESMOD — see SNESMOD Music for the pattern.
- Study the physics: Change GRAVITY, MARIO_ACCEL, and MARIO_JUMPING. Small tweaks completely change how the game feels. This is how real platformers are tuned.
Under the Hood: The Build
The Makefile
TARGET := likemario.sfc
CSRC := main.c
ASMSRC := data.asm
USE_LIB := 1
LIB_MODULES := console sprite sprite_dynamic sprite_lut dma input background
Asset Conversion with gfx4snes
The Makefile includes custom rules for two different asset types:
# Background tiles: 8x8, 4bpp, 16 colors
res/tiles.pic res/tiles.pal: res/tiles.png
$(GFX4SNES) -s 8 -o 16 -u 16 -p -m -i $<
# Mario sprites: 16x16, 4bpp, 16 colors
res/mario_sprite.pic res/mario_sprite.pal: res/mario_sprite.png
$(GFX4SNES) -s 16 -o 16 -u 16 -p -i $<
| 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) |
Why So Many Modules?
Data Placement
data.asm uses two different section types for a reason:
; Graphics — accessed only via DMA, can be in any ROM bank
.SECTION ".rodata1" SUPERFREE
tiles_til: .INCBIN "res/tiles.pic"
mario_sprite_til: .INCBIN "res/mario_sprite.pic"
.ENDS
; Map data — accessed directly by C code, must be in bank $00
.SECTION ".rodata2" SEMIFREE BANK 0
mapmario: .INCBIN "res/BG1.m16" ; Tilemap (u16 per tile)
tilesetatt: .INCBIN "res/map_1_1.b16" ; Collision properties
.ENDS
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.
Technical Reference
| 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 |
Files
| 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 |
Credits
- Original: alekmaul (PVSnesLib example)
- Tileset and sprites: community assets