Just Tiled

Read and write Tiled Map Editor files — TMX/TSX and Tiled's JSON forms, TMJ/TSJ — and draw their tile layers with Canvas.drawRawAtlas. Made for Flutter 2D games.

The package doesn't depend on any game engine. just_game_engine uses it to import and export Tiled files (its TiledInterop); the engine draws and collides its own tile maps.


Features

  • Readers — TMX maps, TSX tilesets and TX object templates; TMJ maps and TSJ tilesets. A TMX map may use a TSJ tileset and a TMJ map a TSX one. Files from Tiled 1.8 to 1.10.
  • Writers — TmxWriter, TsxWriter and TmjWriter write files the Tiled editor opens (the TMX 1.10 format).
  • Every orientation — orthogonal, isometric, staggered (X/Y), hexagonal.
  • Every layer type — tile, object, image and nested group layers.
  • Infinite maps — chunked tile layers (TileLayer.chunks).
  • Every tile encoding — CSV, Base64 and XML; uncompressed, gzip, zlib or Zstandard.
  • Raw tile ids — all four of Tiled's flags kept, including the hexagonal 120° one (TileLayer.rawData, TiledGid).
  • Tilesets — per-tile properties, animation frames, per-tile images, collision shapes, Wang sets (corner, edge, mixed).
  • Objects — rectangle, ellipse, point, polygon, polyline, tile and text objects; templates.
  • Typed custom properties — string, int, float, bool, color, file, object, each written back as its own type.
  • Renderer — one drawRawAtlas call per layer, tile flips, opacity, tint, layer offsets, tile-level culling for large orthogonal layers.
  • Spatial hash grid — fast rectangle, point and radius queries for collision and culling.

Getting started

Add just_tiled to your pubspec.yaml:

dependencies:
  just_tiled: ^0.4.0

Put your maps, tilesets and images under assets/ and declare them:

flutter:
  assets:
    - assets/maps/

Reading maps

TMX

import 'package:flutter/services.dart' show rootBundle;
import 'package:just_tiled/just_tiled.dart';

final tmx = await rootBundle.loadString('assets/maps/desert.tmx');

final map = await TileMapParser.parse(
  tmx,
  tsxProvider: const DefaultTsxProvider(basePath: 'assets/maps'),
);

print('${map.width}x${map.height} tiles, ${map.orientation.name}');

TMJ

final tmj = await rootBundle.loadString('assets/maps/desert.tmj');

final map = await TmjParser.parse(
  tmj,
  tsxProvider: const DefaultTsxProvider(basePath: 'assets/maps'),
);

Both give the same TiledMap. A TMJ map keeps an object's template path (templatePath) but doesn't load the template; a TMX map does.

A tileset on its own

final terrain = TileMapParser.parseTileset(tsxText); // .tsx
final props = TmjParser.parseTileset(tsjText);       // .tsj

Where external files come from

The parsers load external tilesets and templates through a TsxProvider. Without one, an external tileset is left with only its firstGid and source.

DefaultTsxProvider reads from the asset bundle. It joins basePath and the path as written in the map, and doesn't collapse ../, so keep tilesets in the map's folder or below it. For anything else, write your own provider:

import 'dart:io';

class FileTsxProvider implements TsxProvider {
  FileTsxProvider(this.directory);

  final String directory;

  // Asked for .tsx and .tsj files; the parser picks the format by file ending.
  @override
  Future<String> getTsx(String source) =>
      File('$directory/$source').readAsString();

  @override
  Future<String> getTemplate(String source) =>
      File('$directory/$source').readAsString();
}

Working with the data

Objects

for (final group in map.objectGroups) {
  for (final obj in group.objects) {
    print('${obj.name} (${obj.type}) at (${obj.x}, ${obj.y})');
  }
}

map.tileLayers, map.objectGroups and map.imageLayers include the layers inside groups.

Custom properties

final props = map.layers.first.properties;
if (props.has('speed')) {
  final speed = props.getDouble('speed');
}

props.typeOf('door'); // 'object' — an object property holds the object's id

Raw tile ids and flags

TileLayer.data holds ids without flags, with the flips in flipHorizontal, flipVertical and flipDiagonal. The raw ids, all flags included, are in rawData (or rawGidAt(i) / rawGids). A tile object's gid is raw too.

Use TiledGid to take raw ids apart. It works on unsigned values, so it gives the same answers on the web:

final raw = layer.rawGidAt(index);
final gid = TiledGid.idOf(raw);
final mirrored = TiledGid.isFlippedHorizontally(raw);
final turned = TiledGid.isRotatedHex120(raw);

final tileset = map.findTilesetForGid(gid);
final localId = gid - tileset!.firstGid;

Infinite maps

A layer of an infinite map holds its tiles in chunks, and its data is empty:

for (final layer in map.tileLayers.where((l) => l.isChunked)) {
  for (final chunk in layer.chunks) {
    for (var i = 0; i < chunk.data.length; i++) {
      final gid = TiledGid.idOf(chunk.data[i]);
      if (gid == 0) continue;
      final x = chunk.x + i % chunk.width; // in tiles; may be negative
      final y = chunk.y + i ~/ chunk.width;
      // place tile gid at (x, y)
    }
  }
}

Wang sets

for (final set in tileset.wangSets) {
  print('${set.name} (${set.type.name}): '
      '${set.colors.map((c) => c.name).join(', ')}');

  set.wangTiles.forEach((localId, wangId) {
    // Eight slots, clockwise from the top: 0 is none, n is colors[n - 1].
  });
}

Rendering

1. Load the tileset images

import 'dart:ui' as ui;

Future<ui.Image> loadImage(String asset) async {
  final data = await rootBundle.load(asset);
  final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
  return (await codec.getNextFrame()).image;
}

final atlases = [
  for (final tileset in map.tilesets)
    if (tileset.imageSource != null)
      TextureAtlas(
        image: await loadImage('assets/maps/${tileset.imageSource}'),
        tileset: tileset,
      ),
];

A tileset's imageSource is relative to the file that declares the tileset: the TSX for an external one.

2. Make the renderers

A renderer draws one tile layer's tiles from one atlas. For a layer that uses more than one tileset, make one per atlas:

final renderers = [
  for (final layer in map.tileLayers)
    if (layer.visible && !layer.isChunked)
      for (final atlas in atlases)
        TileMapRenderer(map: map, tileLayer: layer, atlas: atlas)..compile(),
];

Call invalidate() after changing a layer's tiles; the next render recompiles it.

When you're done, dispose each atlas once (TextureAtlas.dispose(), or TextureAtlasCollection.dispose() for a list). TileMapRenderer.dispose() disposes its atlas's image too, so don't call it on renderers that share an atlas.

3. Draw in a CustomPainter

render doesn't move the canvas: translate it to your camera first. visibleBounds is the part of the world on screen, used for culling; pass null to draw every tile.

@override
void paint(Canvas canvas, Size size) {
  canvas.save();
  canvas.translate(-camera.dx, -camera.dy);
  final visible = camera & size; // world space

  for (final renderer in renderers) {
    renderer.render(canvas, camera, visible);
  }

  canvas.restore();
}

What the renderer doesn't do

  • Infinite maps. Chunked layers aren't drawn; read chunks yourself.
  • Image-collection tilesets (isImageCollection), which have no single image to make an atlas from.
  • Animated tiles. Frames are read (Tile.animation), but a tile draws its first image.
  • Parallax, the hexagonal 120° flag, and a group's offset, opacity and tint. These are read but not applied.
  • Hidden layers. Skip layers whose visible is false, as above.

Orthogonal layers of more than 10,000 cells are culled tile by tile. Other layers are drawn whole.


Spatial hash grid

final grid = SpatialHashGrid<TiledObject>(cellSize: 128);

for (final obj in map.objectGroups.expand((g) => g.objects)) {
  grid.insert(obj, Rect.fromLTWH(obj.x, obj.y, obj.width, obj.height));
}

final onScreen = grid.query(cameraRect);
final underCursor = grid.queryPoint(cursor);
final inRange = grid.queryRadius(player, 200);

update moves an item and remove takes it out.


Writing files

The writers return text; saving it is up to you.

final tmx = TmxWriter.write(map, encoding: TmxDataEncoding.base64Zlib);
final tsx = TsxWriter.write(map.tilesets.first);

final tmj = TmjWriter.write(map);                    // tile data as number arrays
final packed = TmjWriter.write(map, base64Zlib: true);
final tsj = TmjWriter.writeTileset(map.tilesets.first);

TmxDataEncoding is csv (the default), base64, base64Zlib (Tiled's default) or base64Gzip.

A tileset with a source is written as a reference to its external file; write that file with TsxWriter or TmjWriter.writeTileset. Files follow the TMX 1.10 format: type on tiles and objects, class on everything else.


Integration with just_game_engine

The engine's TiledInterop turns a parsed map into the engine's own tile map and back, and its editor imports and exports Tiled files with it. A TileMapComponent can also name a .tmx or .tmj file directly.


Supported Tiled features

Category Read Write
Files TMX, TSX, TX templates (from TMX); TMJ, TSJ TMX, TSX, TMJ, TSJ
Map orientations Orthogonal, isometric, staggered, hexagonal Same
Layer types Tile (finite and infinite), object, image, group Same
TMX tile data CSV, Base64, XML; none, gzip, zlib, Zstandard CSV, Base64; none, gzip, zlib
TMJ tile data Arrays, Base64; none, gzip, zlib, Zstandard Arrays, Base64 with zlib
Tilesets Embedded, external, several per map, image collections Same
Tile flags Horizontal, vertical, diagonal, hexagonal 120° Same
Tiles Properties, animation, own images, collision shapes Same
Wang sets Corner, edge, mixed; pre-1.5 hex Wang ids Corner, edge, mixed
Objects Rectangle, ellipse, point, polygon, polyline, tile, text Same, with template paths
Properties string, int, float, bool, color, file, object Same
Layer settings Opacity, tint, offset, parallax, locked, class Same

Additional information

Libraries

just_tiled
Tiled Map Editor integration for just_game_engine.