Skip to content

Tile Maps and Collision

magmacrunchmedia edited this page Aug 24, 2026 · 1 revision

Tile Maps and Collision

TileMap

A map is a list of rows of integers, a tile size in pixels, and the set of ids that block movement:

from texastoast import TileMap

tilemap = TileMap(
    [
        [1, 1, 1, 1],
        [1, 0, 0, 1],
        [1, 1, 1, 1],
    ],
    tile_size=16,
    solid_tiles={1},
)

The ids mean nothing to the engine. 0 is conventionally empty ground and 1 a wall, but that is convention, not a rule — solid_tiles is the only thing that gives an id behavior.

Reading

tilemap.get(col, row)            # tile id, or -1 out of bounds
tilemap.set(col, row, tile_id)   # ignored if out of bounds
tilemap.is_solid(col, row)
tilemap.is_solid_at(world_x, world_y)
tilemap.to_grid_coords(world_x, world_y)   # -> (col, row)

tilemap.rows, tilemap.cols       # in tiles
tilemap.width, tilemap.height    # in pixels

Two behaviors to know:

  • get() returns -1 out of bounds, which is also what a ragged row returns past its end. If you use -1 as a real tile id, it will be indistinguishable from "not there".
  • Out of bounds counts as solid. is_solid(-1, 0) is True. This means an entity cannot walk off the edge of the map even without a wall drawn there, and a short row in a ragged grid behaves as a wall.

Files

tilemap.save("map.json")
tilemap = TileMap.from_file("map.json")
tilemap = TileMap.from_file("map.json", tile_size=32, solid_tiles={1, 2})

Arguments passed to from_file override what the file says. The format is plain JSON:

{
  "grid": [[1, 1, 1], [1, 0, 1], [1, 1, 1]],
  "tile_size": 16,
  "solid_tiles": [1]
}

This is what the Tile Editor writes.

Entity

from texastoast import Entity

player = Entity(x=0, y=0, width=16, height=16, speed=100)

speed is pixels per second. move() takes the frame's dt:

player.move(dx, dy, dt, tilemap)   # collide against the map
player.move(dx, dy, dt)            # no collision, just integrate

dx/dy are a direction, normally -1, 0 or 1 per axis — exactly what InputState.dx/dy give you. A diagonal is normalized, so holding two directions is the same speed as one. A partial direction like 0.5 is left alone, so analog input still works.

player.center_x, player.center_y   # useful for camera follow and distances
player.vel_x, player.vel_y         # px/second, set by the last move()
player.aabb                        # AABB for overlap tests
player.collides_with(other)

update(dt) is a no-op hook for subclasses.

How collision works

check_tile_collision resolves the two axes independently: horizontal first, then vertical from the corrected horizontal position. That is what makes an entity slide along a wall instead of catching on it — pushing diagonally into a wall still moves you along it.

Three properties are worth relying on:

It stops flush. A blocked entity is placed exactly against the wall face, not returned to where it started. Walking into a wall leaves no gap.

It cannot tunnel. Movement longer than one tile is split into sub-steps of at most tile_size, so a fast entity is stopped by a wall it would otherwise skip over in a single frame. The subdivision is capped at collision.MAX_SUBSTEPS (256); past that — a step of thousands of pixels in one frame — tunneling becomes possible again.

Boxes are half-open. A box at x with width w covers [x, x + w). A box whose right edge lands exactly on a tile boundary does not occupy the tile beyond it, which is what keeps flush contact stable frame after frame.

Entities inside walls

Collision assumes the entity starts somewhere legal. An entity spawned inside a solid tile is stuck — nothing pushes it out, because only the leading edge of a move is tested. Check your spawn points:

col, row = tilemap.to_grid_coords(player.x, player.y)
assert not tilemap.is_solid(col, row), "spawned inside a wall"

Remember an entity covers more than one tile if it is larger than tile_size or straddles a boundary.

AABB

A plain axis-aligned box for overlap tests, independent of the tile map:

from texastoast import AABB

box = AABB(x=0, y=0, width=10, height=10)
box.left, box.right, box.top, box.bottom
box.intersects(other)          # touching edges do NOT count as overlapping
box.contains_point(px, py)     # edges DO count as inside

The two differ on edges deliberately: intersects is used for collision, where flush contact must not read as overlap, while contains_point is for hit testing, where clicking the border should hit.

Clone this wiki locally