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.
| coords | source_id | atlas | alternative | transform |
|---|
Write x, y, erase to emit a tombstone. Coordinates outside −32768…32767 and alternative ids over 4095 are refused rather than wrapped — both wrap silently in the file and are invisible until the scene is reopened.
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.
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 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.
| Offset | Size | Type | Field | Notes |
|---|---|---|---|---|
+0 | 2 | int16 | coords.x | map coordinate, signed |
+2 | 2 | int16 | coords.y | map coordinate, signed |
+4 | 2 | uint16 | source_id | TileSet source id; 0xFFFF = erased |
+6 | 2 | uint16 | atlas_coords.x | column in the atlas source |
+8 | 2 | uint16 | atlas_coords.y | row in the atlas source |
+10 | 2 | uint16 | alternative_tile | alternative 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.
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.
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)
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.
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.
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.
| Problem | What the engine prints | Result |
|---|---|---|
| header > 0 | Unsupported tile map data format: N. Expected format ID lower or equal to: 0 | The whole assignment is refused. The layer keeps its previous contents — this is a no-op, not a clear. |
(len - 2) % 12 != 0 | Corrupted 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.
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.
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:
TileSet before touching TileData. Terrain and physics layers only propagate to tiles once the source belongs to a TileSet. Call ts.add_source(src, id) first, configure tiles after — the other way round, set_terrain_peering_bit() writes into a tile with no terrain layer to write to..tres is a quoted-string format — escape what you put in it. A terrain name containing a " or a \ produced a file Godot refused to load with Parse Error: Unterminated string.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.
TileMapLayer class reference — the public API this format backs.TileSetAtlasSource transform constants — the 0x1000 / 0x2000 / 0x4000 flags.