Skip to content
Techniques & Technology

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.

commodore-64sinclair-zx-spectrumcommodore-amiganintendo-entertainment-systemgameplaycollisionplatformers

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.

See also

Not yet fact-checked. This entry was drafted by an AI and nobody has verified it. The dates, figures and technical details may be wrong. Use it to find your bearings, then confirm anything that matters against a primary source.