Godot 4 stores your whole tilemap in one property, and it is a wall of integers

Everything you painted into a TileMapLayer lives in tile_map_data, a PackedByteArray that lands in your .tscn as a few hundred numbers. The class reference does not document its layout. Paste yours below and get the cells back — or build a buffer from a cell table, if you are generating scenes from outside the editor.

Paste the whole line, just the PackedByteArray(…), the bare numbers, or a hex dump — all four are read. Nothing is uploaded; this runs in your browser.

coordssource_idatlasalternativetransform

    Every rule on this page was measured against Godot 4.7 stable by a script that paints, erases, saves and reloads a real TileMapLayer and checks what comes back — verify_tile_map_data.gd, 46 claims, run on demand. The decoder above is held to the same engine: dump_tile_map_fixtures.gd dumps twelve buffers the engine produced along with the cells it reads back from them, and the decoder has to reproduce that read-back exactly. The buffers the Build tab writes are handed back to a real engine and must paint the cells you typed.

    The layout

    A 2-byte header, then a flat run of 12-byte cell records. Nothing else: no cell count, no bounding box, no index, no compression, no padding. Every value is little-endian.

     0        2                14                26
     ┌────────┬────────────────┬────────────────┬─────  ···
     │ format │  cell record   │  cell record   │
     │ uint16 │    12 bytes    │    12 bytes    │
     └────────┴────────────────┴────────────────┴─────  ···

    So a valid buffer always satisfies (len - 2) % 12 == 0, and the record count is (len - 2) / 12. The record count is not the cell count — see the tombstones below, which is the trap that sends people here.

    The header

    The only version that exists is 0: the enum TileMapLayerDataFormat in scene/2d/tile_map_layer.h has exactly one member. An empty layer serializes as an empty array, not as a lone 2-byte header.

    The cell record

    OffsetSizeTypeFieldNotes
    +02int16coords.xmap coordinate, signed
    +22int16coords.ymap coordinate, signed
    +42uint16source_idTileSet source id; 0xFFFF = erased
    +62uint16atlas_coords.xcolumn in the atlas source
    +82uint16atlas_coords.yrow in the atlas source
    +102uint16alternative_tilealternative id plus transform flags

    The two coordinate fields are the only signed ones. Negative coordinates are plain two's complement, so a cell at (-1, -1) starts with four ff bytes.

    Transform flags live in the alternative id

    The last field is not just the alternative-tile id — the three cell transforms are packed into its high bits:

     15   14   13   12   11 ─────────────────── 0
    ┌────┬────┬────┬────┬──────────────────────┐
    │ ?  │ T  │ V  │ H  │  alternative id      │
    └────┴────┴────┴────┴──────────────────────┘
    
    0x1000  TRANSFORM_FLIP_H
    0x2000  TRANSFORM_FLIP_V
    0x4000  TRANSFORM_TRANSPOSE

    So alternative_id = field & 0x0FFF, and each flag is a plain bit test. Two consequences worth knowing: practical alternative ids stop at 4095, and you must read the field as unsigned — Godot's own get_cell_alternative_tile() sign-extends it, so a value with bit 15 set comes back as a large negative number instead of the id you wrote.

    A worked example, byte by byte

    Three cells — a plain tile, a horizontally flipped one, and one at negative coordinates — all using source id 7. This is what the Load the worked example button pastes above, and the bytes below are the ones a real engine wrote for exactly these three set_cell() calls.

           coords.x  coords.y  source_id  atlas.x  atlas.y  alternative
    head   -- -- 00 00
    cell 0    01 00     02 00      07 00    01 00    01 00        00 00   → (1,2)   src 7 atlas (1,1)
    cell 1    02 00     02 00      07 00    00 00    00 00        00 10   → (2,2)   src 7 atlas (0,0) flip H
    cell 2    ff ff     ff ff      07 00    00 00    00 00        00 00   → (-1,-1) src 7 atlas (0,0)

    Note cell 1's 00 10: little-endian 0x1000, the flip-H bit. The byte order is the thing people misread here. As it appears in the scene file:

    tile_map_data = PackedByteArray(0, 0, 1, 0, 2, 0, 7, 0, 1, 0, 1, 0, 0, 0, 2, 0, 2, 0, 7, 0, 0, 0, 0, 0, 0, 16, 255, 255, 255, 255, 7, 0, 0, 0, 0, 0, 0, 0)

    The four things that will bite you

    1. Erased cells leave a record behind

    erase_cell() does not remove the 12 bytes. It overwrites the record's source_id, atlas_coords and alternative with 0xFFFF and leaves the coordinates where they were:

    before erase_cell((1,1)):  0000 010001000700000000000000 020002000700010000000000
    after  erase_cell((1,1)):  0000 01000100ffffffffffffffff 020002000700010000000000
                                             ^^^^ ^^^^^^^^^^^^^^^ tombstone

    The buffer does not shrink, the tombstone is written to the .tscn, and it survives a reload. So cell count ≠ (len − 2) / 12; painted cells are the records whose source_id is not 0xFFFF. A parser that trusts every record reports phantom tiles with a source id of 65535 at atlas coords (65535, 65535). And a layer that was painted and then cleared serializes as a buffer full of tombstones, not as an empty one.

    2. Coordinates outside int16 are silently truncated

    The map coordinate is 16 bits, but set_cell() accepts a full Vector2i. In memory the cell keeps the coordinate you gave it. The moment it is serialized, it wraps:

    layer.set_cell(Vector2i(40000, -40000), 7, Vector2i(0, 0), 0)
    layer.get_cell_source_id(Vector2i(40000, -40000))     # 7 — fine, in memory
    
    # ... save, reload ...
    reloaded.get_used_cells()                             # [(-25536, 25536)] — moved
    reloaded.get_cell_source_id(Vector2i(40000, -40000))  # -1 — gone

    No warning, no error. The usable range is −32768…32767 per axis, and both extremes round-trip exactly. If you generate maps from world coordinates, clamp or chunk before you write, or tiles teleport across the map on the next load.

    3. Record order is insertion order, and duplicates are legal

    Records come out in the order cells were set — not sorted by coordinate, not row-major. Do not rely on it for diffing; sort by coordinate yourself. A buffer may also contain two records for the same coordinate: the engine applies them in order, so the last record wins, and re-serializing collapses them to one. Handy for appending, but it means a hand-written buffer's record count can legitimately exceed the number of cells that end up on the map.

    4. Malformed buffers fail in two very different ways

    ProblemWhat the engine printsResult
    header > 0Unsupported tile map data format: N. Expected format ID lower or equal to: 0The whole assignment is refused. The layer keeps its previous contents — this is a no-op, not a clear.
    (len - 2) % 12 != 0Corrupted tile map data: tiles might be missing.Complete records load; the trailing partial record is dropped.

    The second is a partial success that prints an error and carries on, which is easy to miss in a noisy log. If you generate buffers, assert the length rule on your side before assigning — the decoder above does exactly that, and tells you how many records survived.

    Reading and writing it in GDScript

    The encoder, straight from the offsets above:

    const HEADER_SIZE := 2
    const RECORD_SIZE := 12
    
    func encode(cells: Array) -> PackedByteArray:
        var buf := PackedByteArray()
        buf.resize(HEADER_SIZE + cells.size() * RECORD_SIZE)
        buf.encode_u16(0, 0)  # format 0
        for i in cells.size():
            var c: Dictionary = cells[i]
            var o := HEADER_SIZE + i * RECORD_SIZE
            buf.encode_s16(o + 0, c.coords.x)
            buf.encode_s16(o + 2, c.coords.y)
            buf.encode_u16(o + 4, c.source_id)
            buf.encode_u16(o + 6, c.atlas.x)
            buf.encode_u16(o + 8, c.atlas.y)
            buf.encode_u16(o + 10, c.get("alternative", 0))
        return buf

    And the decoder. That continue is not optional — it is trap #1:

    func decode(buf: PackedByteArray) -> Array:
        assert(buf.size() >= HEADER_SIZE and (buf.size() - HEADER_SIZE) % RECORD_SIZE == 0)
        assert(buf.decode_u16(0) == 0)
        var out := []
        for o in range(HEADER_SIZE, buf.size(), RECORD_SIZE):
            var source_id := buf.decode_u16(o + 4)
            if source_id == 0xFFFF:
                continue  # erased cell — the tombstone
            var alt := buf.decode_u16(o + 10)
            out.append({
                "coords": Vector2i(buf.decode_s16(o + 0), buf.decode_s16(o + 2)),
                "source_id": source_id,
                "atlas": Vector2i(buf.decode_u16(o + 6), buf.decode_u16(o + 8)),
                "alternative": alt & 0x0FFF,
                "flip_h": bool(alt & 0x1000),
                "flip_v": bool(alt & 0x2000),
                "transpose": bool(alt & 0x4000),
            })
        return out

    The JavaScript running in this page is the same thing with the engine's imperfect-buffer behaviour added — it is one readable file, MIT, take it.

    Why we know this

    We build Blobsmith, which turns 6 hand-drawn tiles into a 47-tile blob autotile sheet, and a free MIT Godot addon that wires such a sheet into a paint-ready TileSet from inside the editor. Producing tilemaps from outside the editor is how we spend our days, and this property is undocumented, so we measured it. Three things that cost us time and are not format details:

    The full write-up, the verification script and the fixtures live in the addon repo: docs/tile-map-data-format.md. Corrections welcome — open an issue with a failing case and the buffer that produced it.

    Related