Tile-Based Collision
Efficient collision detection for platformers
By checking which tiles a character overlaps rather than testing every object, tile collision provides fast, memory-efficient collision detection for platform games.
In tile-based games, the world is made of a grid. Instead of checking collision against every platform and wall, you check which tiles the player overlaps and whether those tiles are solid. This reduces collision detection from O(n) object comparisons to O(1) tile lookups.
Tile map structure
Map: 40×25 tiles, 1 byte each
+--+--+--+--+--+--+--+--+
| 0| 0| 0| 0| 0| 0| 0| 0| 0 = empty (sky)
+--+--+--+--+--+--+--+--+
| 0| 0| 0| 0| 0| 0| 1| 1| 1 = solid (ground)
+--+--+--+--+--+--+--+--+
| 1| 1| 1| 0| 0| 1| 1| 1| 2 = platform (solid top only)
+--+--+--+--+--+--+--+--+
Basic collision check
Given a pixel position, find the tile:
; Convert pixel position to tile coordinates
; Assuming 8×8 tiles
pixel_to_tile_x:
lda player_x
lsr
lsr
lsr ; divide by 8
rts
pixel_to_tile_y:
lda player_y
lsr
lsr
lsr ; divide by 8
rts
; Get tile at (tile_x, tile_y)
; For map width = 40 we use × 40 = (tile_y << 5) + (tile_y << 3) — × 32 + × 8
; For non-power-of-2 widths, a precomputed row-offset lookup table is cleaner:
; row_offset_lo[y], row_offset_hi[y]
; This avoids the multiply entirely (each row's address is in the table).
get_tile:
; Compute tile_y × 40 cleanly: (y << 5) + (y << 3)
lda tile_y
asl
asl
asl ; A = tile_y × 8 (also we'll add this back after the next shifts)
sta tmp ; save × 8
asl
asl ; A = tile_y × 32
clc
adc tmp ; A = × 32 + × 8 = × 40
clc
adc tile_x
tax
lda map_data,x
rts
Note: a 1000-byte map (40 × 25) crosses a 256-byte page in map_data, so the index needs to be 16-bit in production code (map_data + tile_y × 40 + tile_x may exceed 255). Use a row-pointer table for clean handling:
row_lo: .byte <(map_data + 0*40), <(map_data + 1*40), ..., <(map_data + 24*40)
row_hi: .byte >(map_data + 0*40), >(map_data + 1*40), ..., >(map_data + 24*40)
get_tile:
ldy tile_y
lda row_lo,y
sta ptr
lda row_hi,y
sta ptr+1
ldy tile_x
lda (ptr),y
rts
Collision points
Check multiple points around the player:
Player hitbox (16×16):
+--+--+
|TL TR| TL = top-left
| | TR = top-right
|BL BR| BL = bottom-left
+--+--+ BR = bottom-right
Horizontal movement
check_horizontal:
; Moving right? Check TR and BR
lda velocity_x
bmi .check_left
beq .no_collision
; Check right edge
lda player_x
clc
adc #15 ; player width - 1
sta check_x
; Check top-right
lda player_y
sta check_y
jsr get_tile_at_point
cmp #TILE_SOLID
beq .collision
; Check bottom-right
lda player_y
clc
adc #15
sta check_y
jsr get_tile_at_point
cmp #TILE_SOLID
beq .collision
.no_collision:
rts
.check_left:
; Similar for left edge
...
.collision:
; Align to tile boundary
lda player_x
and #$f8 ; snap to 8-pixel grid
sta player_x
lda #0
sta velocity_x
rts
Vertical movement (falling)
check_falling:
; Check below player
lda player_y
clc
adc #16 ; just below feet
sta check_y
; Check both feet positions
lda player_x
sta check_x
jsr get_tile_at_point
cmp #TILE_SOLID
beq .on_ground
lda player_x
clc
adc #15
sta check_x
jsr get_tile_at_point
cmp #TILE_SOLID
beq .on_ground
; Not on ground - apply gravity
lda #1
sta is_falling
rts
.on_ground:
lda #0
sta is_falling
sta velocity_y
; Snap to tile top
lda player_y
clc
adc #8
and #$f8
sta player_y
rts
Tile types
TILE_EMPTY = 0 ; passable
TILE_SOLID = 1 ; blocked all sides
TILE_PLATFORM = 2 ; solid from above only
TILE_LADDER = 3 ; climbable
TILE_HAZARD = 4 ; damages player
TILE_WATER = 5 ; swimmable
Platform tiles (one-way)
check_platform:
; Only solid when falling down onto it
lda velocity_y
bmi .not_solid ; moving up, pass through
; Check if feet are above platform
lda player_y
clc
adc #15 ; feet position
and #$07 ; position within tile
cmp #2 ; near top of tile?
bcs .not_solid ; too far in, let them pass
; Treat as solid
...
.not_solid:
rts
Slopes
More complex but achievable:
; Slope tile contains height map
; Each column of the tile has different height
slope_heights:
.byte 7, 6, 5, 4, 3, 2, 1, 0 ; upward slope
check_slope:
; Get X position within tile
lda player_x
and #$07
tax
lda slope_heights,x
; Compare to player Y within tile
lda player_y
and #$07
cmp slope_heights,x
bcc .above_slope
; On or below slope surface
...
Optimisation
Coarse-fine check
First check if player moved to a new tile:
lda player_tile_x
cmp last_tile_x
bne .check_needed
lda player_tile_y
cmp last_tile_y
beq .skip_check ; same tile, no collision possible
.check_needed:
jsr full_collision_check
.skip_check:
Tile attribute table
Separate collision data from visual tiles:
; Visual map uses tiles 0-255 for graphics
; Collision map uses simplified types
visual_map: .res 1000
collision_map: .res 1000 ; parallel array
On the Commodore 64
The VIC-II offers a shortcut. Commodore’s Programmer’s Reference Guide documents a sprite-to-data collision register at $D01F: a bit per sprite, set when the sprite’s pixels overlap any background pixel, and held until read. The guide’s advice for using it is a tile-design rule — in multicolour mode “data 01 is considered transparent for collisions”, so “it is a good idea to make everything that should not cause a collision 01”. The register says that a sprite touched something, not what or where, which is why platformers read the map instead.
The map-reading version on the C64 is character arithmetic. Achim’s Codebase64 routine converts a sprite’s coordinates into a screen cell: subtract the visible area’s origin ($18 in X, $32 in Y), shift right three times to divide by 8, look up the screen row’s address from a table, and read the character code at that column. Neighbouring cells are the same column plus 1, 2, 40 or 80.
Cadaver (Lasse Öörni) explains why a scrolling game should check the map rather than the screen. His “Rant 4”, which credits Jukka Tapanimäki’s C-64 Pelintekijän Opas (“C-64 Game Maker’s Guide”), describes the SEUCK-style map-and-block system — he prefers 4×4-character blocks, a power of two “for easy calculations” — and points at Turrican’s walkers, which misbehave at the screen edge because they check “only the characters on screen for background collisions”. His Metal Warrior 1 and 2 stored world coordinates as 16-bit pixel positions and paid for it with slow collision checks and no subpixel movement. Metal Warrior 3 and BOFH instead keep the block number in the high byte and the position within the 32-pixel block in the low byte, so “the map position is directly the coordinate highbyte” and the low byte’s spare three bits are subpixel precision.