2025, Oct 19 15:00

Fixing Tiled collision in pygame with pytmx: per-tile properties vs tileset properties and gid mapping

Learn why collision checks fail in pygame+pytmx when Tiled properties sit on the tileset, not the tile. Fix it, read properties by gid, avoid hard-coded checks.

When you experiment with custom properties in Tiled and wire them up in pygame via pytmx, a common gotcha is that properties exist but never show up at runtime. The symptom is deceptively simple: collision checks always return false, so the player happily walks through tiles that should be blocked. The root cause in this case has nothing to do with pygame or movement logic and everything to do with where the property lives in the TMX.

Problem setup

The tileset in the map defines a boolean named “Colider”, and the layer uses gid 2 for those tiles. The Python side queries tile properties using get_tile_properties_by_gid(gid), expecting to see that flag and stop the player’s movement. Instead, the property read returns nothing and the check never triggers.

<tileset firstgid="2" name="pixil-frame-0" tilewidth="32" tileheight="32" tilecount="1" columns="1">
  <properties>
    <property name="Colider" type="bool" value="true"/>
  </properties>
  <image source="pixil-frame-0.png" width="32" height="32"/>
</tileset>

Here is a minimal pygame + pytmx example that demonstrates the issue on the code side. Names are arbitrary, the logic is straightforward: render the map, move the player in 32px steps, and block movement if the target tile has Colider set to true.

import pygame
import pytmx

pygame.init()
VIEW_W, VIEW_H = 30 * 32, 20 * 32
canvas = pygame.display.set_mode((VIEW_W, VIEW_H))
ticker = pygame.time.Clock()

# --- Load TMX Map ---
tmx_map = pytmx.load_pygame(r'C:\Users\miked\OneDrive\Documents\GitHub\RFIDCard\map-rpg-game\Test_map.tmx')
cell_w = tmx_map.tilewidth
cell_h = tmx_map.tileheight

def paint_map():
    for lyr in tmx_map.visible_layers:
        if isinstance(lyr, pytmx.TiledTileLayer):
            for gx, gy, gid in lyr:
                bmp = tmx_map.get_tile_image_by_gid(gid)
                if bmp:
                    canvas.blit(bmp, (gx * cell_w, gy * cell_h))

class Avatar:
    def __init__(self, px, py):
        self.px = px
        self.py = py
        self.sprite = pygame.Surface((32, 32))
        self.sprite.fill((255, 0, 0))

    def hits_block(self, tx, ty, tmx_obj):
        for lyr in tmx_obj.visible_layers:
            if isinstance(lyr, pytmx.TiledTileLayer):
                try:
                    gid = lyr.data[ty][tx]
                except IndexError:
                    continue
                if gid == 0:
                    continue
                props = tmx_obj.get_tile_properties_by_gid(gid)
                if props and props.get("Colider") is True:
                    return True
        return False

    def render(self, surf):
        surf.blit(self.sprite, (self.px, self.py))

    def shift(self, dx, dy):
        next_tx = (self.px + dx) // cell_w
        next_ty = (self.py + dy) // cell_h
        if not self.hits_block(next_tx, next_ty, tmx_map):
            self.px += dx
            self.py += dy

    def handle_input(self, keys_state):
        if keys_state.get('up') or keys_state.get('w'):
            self.shift(0, -32)
        if keys_state.get('down') or keys_state.get('s'):
            self.shift(0, 32)
        if keys_state.get('left') or keys_state.get('a'):
            self.shift(-32, 0)
        if keys_state.get('right') or keys_state.get('d'):
            self.shift(32, 0)

hero = Avatar(5 * 32, 5 * 32)

# --- Main Loop ---
running = True
while running:
    for evt in pygame.event.get():
        if evt.type == pygame.QUIT:
            running = False

    keys = pygame.key.get_pressed()
    hero.handle_input({
        'up': keys[pygame.K_UP],
        'down': keys[pygame.K_DOWN],
        'left': keys[pygame.K_LEFT],
        'right': keys[pygame.K_RIGHT],
        'w': keys[pygame.K_w],
        'a': keys[pygame.K_a],
        's': keys[pygame.K_s],
        'd': keys[pygame.K_d],
    })

    canvas.fill((0, 0, 0))
    paint_map()
    hero.render(canvas)
    pygame.display.flip()
    ticker.tick(10)

What actually goes wrong

The property lookup returns None because the property is attached to the tileset itself, not to an individual tile inside that tileset. The call get_tile_properties_by_gid(gid) returns properties for a specific tile element, which means the data has to live under a tile node. In other words, props is always None, so the check for Colider never fires and movement is never blocked.

There is also a mapping detail that explains why gid 2 matters here. A tile’s gid is computed as gid = firstgid + id. With firstgid set to 2 and a single tile defined as id 0, the gid you see in the layer data is 2.

Fixing the data, not the code

The durable fix is to move the property into a tile element inside the tileset. After that, get_tile_properties_by_gid(2) will return a dictionary that actually includes Colider.

<tileset firstgid="2" name="pixil-frame-0" tilewidth="32" tileheight="32" tilecount="1" columns="1">
  <tile id="0">
    <properties>
      <property name="Colider" type="bool" value="true"/>
    </properties>
  </tile>
  <image source="pixil-frame-0.png" width="32" height="32"/>
</tileset>

After this change, a quick probe confirms that the lookup now yields the expected structure.

props = tmx_map.get_tile_properties_by_gid(2)
print(props)
{'id': 0, 'Colider': True, 'width': 32, 'height': 32, 'frames': []}

The gameplay code does not need to change. The movement routine will correctly block when props.get("Colider") is True.

If the property really belongs to the tileset

If the intent is to store a value at the tileset level rather than per tile, it can be read from the tilesets collection. Because tilesets is a list, this access does not use gid and instead relies on the index of the tileset.

val = tmx_map.tilesets[1].properties.get("Colider")

This is different from per-tile properties and won’t affect get_tile_properties_by_gid(gid). Use it only when the property is truly meant to describe the entire tileset.

Why this matters

Mixing tileset-level and tile-level properties changes how your loader retrieves data. Hard-coding gid numbers, like treating 2 as blocking, might feel like a quick fix but ties behavior to a fragile identifier. Correctly placing properties inside the tile element makes the game logic data-driven and keeps the code resilient as the map evolves. It also makes debugging straightforward, because print(props) tells you immediately whether you’re reading the right place.

Takeaways

When a property check always fails, verify where the data lives. For per-tile logic such as collision, define properties under the tile element and remember the mapping gid = firstgid + id when reasoning about layer data. Use quick print debugging to inspect the objects you read, and only fall back to tileset-level access if that is truly what you need. With properties correctly placed, the same collision code works as intended and scales without hard-coded gid checks.

The article is based on a question from StackOverflow by F-22 Destroyer and an answer by furas.