An atproto Tilemap editor
README.md

Lexicons #

ATProto schema definitions for tilemap.world records. NSID authority is the project domain reversed: tilemap.world → world.tilemap.*.

world.tilemap.tileset #

Mirrors the editor's Tileset save format (src/store/persistence.ts). Record key is a tid (many tilesets per repo); the editor's stable UUID lives in stableId. Maps reference a tileset by the AT-URI of its record (see world.tilemap.map's tilesets), so every tileset a map uses must be a published record.

TypeScript types for this record live in src/lexicon/tileset.ts (TilesetRecord and friends). persistence.ts converts between them and the local save format with tilesetToRecord / recordToTileset — those take a blob upload/resolve callback (the only field that can't match locally; see below), so the actual PDS plumbing lives in the future sync layer.

Differences from the JSON save format #

The JSON file uses two Record<string, …> maps keyed by "col,row". Lexicon objects can't have dynamic keys, so these become flat arrays of entries that carry their own col/row:

Save format (Tileset) Lexicon record
sectionAssignments: Record<key, …> sectionAssignments: #sectionAssignment[]
tileProperties: Record<key, Property[]> tileProperties: #tileProperty[]
version: number formatVersion: integer
id: string stableId: string (rkey is a separate tid)

properties, collision, frames, variations are already arrays and map 1:1.

Image: embedded vs. external (the redistribution case) #

image is a union of two variants:

  • #embeddedImage — the sheet stored as a blob in the repo. Use only for art that may be redistributed.
  • #externalImage — no bytes are stored. A descriptor (name, width, height, optional sha256, source, author, license) for a sheet the consumer must supply locally. This is the path for assets that are not licensed for redistribution: the record captures the grid geometry and how to obtain the file, the editor prompts the user for it, and — when sha256 is present — verifies the supplied file is the expected one.

image is omitted entirely for a tileset that has no sheet yet.

License (license) — open enum #

A string holding an SPDX identifier. Modeled as an open enum (knownValues, not enum): the editor suggests a set but any SPDX id — or other string — is valid. The suggested set is GitHub's commonly-offered licenses (GET /licenses, 13 entries) plus the Creative Commons family relevant to art assets (CC-BY-4.0, CC-BY-SA-4.0, …). Tangled has no license lexicon, so there's no prior art to match here; the full SPDX list (~600 ids) lives at https://spdx.org/licenses/.

There's a separate license on #externalImage — that one is about the sheet's own licensing; this top-level license covers the tileset's contents.

world.tilemap.map #

A map is a tree of scene nodes authored in a concrete pixel coordinate space over a single tile grid (#gridConfig: tileWidth/tileHeight in pixels). Pixel sizes are part of the map now — they are not deferred to export. The map references the tilesets it paints/places with in its tilesets list (#tilesetRef[]).

Multiple tilesets (tilesets) — per-reference binding #

A map may draw from several tilesets at once. Each #tilesetRef binds a short local ref alias (a, b, …, unique within the map) to a tileset record's AT-URI (uri) — every tileset is a record, referenced by its at://…/world.tilemap.tileset/… URI. Every place that names a material or object then carries the alias, so a single layer can freely mix tiles from different tilesets (Tiled-style):

Reference Tileset named by
#cellPaint / #edgePaint / #cornerPaint tileset alias (omitted for the void sentinel — it is tileset-independent)
#objectNode tileset alias (required)
#tileBackground tileset alias (required)

Material ids and object ids are unique only within their own tileset, so the alias is what disambiguates them. The first tilesets entry is treated as primary where a single tileset is implied. The list is empty/omitted for a map that references no tileset yet.

Record key is a tid (many maps per repo); the editor's stable UUID lives in stableId so a world.tilemap.world can keep referencing maps by id across syncs. TypeScript types live in src/lexicon/map.ts (MapRecord and friends).

Nodes (a $type-discriminated union) #

nodes is an array of a union; a group may nest further nodes via its children. Array order is draw order (first drawn first). The five kinds:

Node Holds
#groupNode children[] — organising container, carries no art
#canvasNode layers[] — one or more painted material layers, drawn bottom-to-top
#objectNode tileset + objectId + pixel x,y — a placed tileset object
#areaNode boxes[] — a region; shape is the additive union of the boxes

Every node carries id, optional name/hidden, and a free-form properties bag (the same escape hatch tilesets use).

Collision is an area, not its own kind. A movement-blocking region is an #areaNode carrying the reserved property world.tilemap.collision = 1 (COLLISION_PROPERTY/isCollision in src/lexicon/map.ts). This sits in the world.tilemap.* reserved-key namespace alongside world.tilemap.material and world.tilemap.weight, so consumers recognise collision without app-specific knowledge.

Canvas layers & the grid offset #

A #canvasNode holds an ordered layers[]; layer order is a strict draw order (index 0 = bottom). The whole canvas takes one depth band via sort (dynamic / ground / sky — see below). Each #canvasLayer paints with the material approach in three channels and may shift the map grid by a pixel offset (offsetX/offsetY) — e.g. with a 48px grid, 24,24 staggers a layer half a cell. The three channels mirror the tileset's dynamic-key problem: at runtime they are keyed by col,row (and edge orientation / corner), so on the wire each becomes a flat array of entries carrying its own coordinates:

Runtime (keyed map) Lexicon (flat list) Entry carries
cells: Record<"c,r", mat> cells: #cellPaint[] col, row
edges: Record<edgeKey, mat> edges: #edgePaint[] orient, col, row
corners: Record<cornerKey,…> corners: #cornerPaint[] corner, col, row

cells fills (material is a tileset material id — with tileset naming which tileset by its local ref — or the void sentinel for an explicit empty cell, in which case tileset is omitted), edges adds feature strokes over the auto-computed Wang transitions, and corners are quarter-cell dot overrides. Each of the three channels carries its own tileset alias, so one layer can mix materials from different tilesets.

Depth sorting — bands & slices #

Depth runs along one map-level depthAxis (y default, or x; larger coordinate = nearer the viewer). Objects sort by their origin coordinate on that axis. Each canvas picks a sort band:

  • dynamic (default) — joins the depth pool and interleaves with objects.
  • ground — pinned behind all dynamic content (the base ground). At most one canvas in the map holds this band.
  • sky — pinned in front of all dynamic content (overlay). Also unique.

A dynamic canvas's layers can each be cut into bands by slices — an ascending list of cut positions in map pixels along the axis. Each band interleaves with objects at its own far-edge depth (the band's largest coordinate on the axis); an empty slices means the whole layer is one band.

This is what makes a fenced-in area work without per-object data: paint the fence on a dynamic canvas and drop a cut between its north and south runs (and a few down the sides for per-row precision). A player inside then draws in front of the bands above their feet and behind the bands below. Slicing is author-controlled, so the common case costs nothing and only the fence carries a short int array — the minimal sort dataset.

(Genuinely unit-sorted tall art should be an #objectNode, which sorts as one unit by its feet, rather than painted cells.)

Attribution (attribution) — DID or name+url #

An array of #credit, each pairing an #agent with an open-enum role (creator, contributor, artist, …). An #agent is identified either by atproto identity (did, with handle as a display hint) or, for creators not on atproto, by name (+ optional url). At least one of did / name is expected. (Tangled credits people by DID only; we also allow name+url so off-network artists can be credited.)