← LOGBOOK LOG-435
EXPLORING · SOFTWARE ·
TILEDRUBYRUBY2DGAME-DEVELOPMENTTILEMAPS

Tiled and Ruby2D

Tiled JSON maps establish a data boundary between level authoring and runtime systems for drawing, spawning, and collision.

Tiled is a level editor, not a game engine. It stores the world: tile placement, layers, object positions, and custom properties. Ruby2D remains responsible for loading that data, creating visible objects, moving entities, and resolving collision. The useful boundary is simple: the editor defines what exists in a level; the runtime defines how it behaves.

Tiles, Tilesets, and Maps

A tile is a small reusable image, commonly 16×16, 32×32, or 48×48 pixels. A tileset is a larger image containing many tiles arranged on that same grid. Grass, dirt, walls, water, and decoration can all occupy different cells in a single tileset image. A map places references to those tiles into a larger grid.

The tile size is a contract across the entire pipeline. A 32×32 tileset must be configured in Tiled with a tile width and height of 32. Ruby2D must use the same dimensions when it cuts the image into drawable tiles. Incorrect width, height, spacing, or margin values shift every source rectangle and produce tiles bleeding into their neighbours.

An orthogonal map with a 32-pixel tile size, 30 columns, and 20 rows describes a 960×640-pixel level. A compact initial map uses five layers:

Background   painted colour or distant scenery
Ground       terrain drawn behind entities
Collisions   solid rectangles or polygons; not necessarily visible
Entities     player spawn, enemies, pickups, exits
Foreground   tiles drawn over entities

The Stamp Brush paints tiles into tile layers. Object layers place points, rectangles, polygons, and tile objects at arbitrary pixel positions. A player spawn belongs in Entities, not in a decorative tile layer; a solid floor belongs in Collisions, not in the visual art alone.

The Map as Data

An orthogonal Tiled map has a width and height measured in tiles and a tilewidth and tileheight measured in pixels. A 30-column map with 32-pixel tiles is 960 pixels wide. Tile coordinates and pixel coordinates are related by:

pixel_x = tile_x × tile_width
pixel_y = tile_y × tile_height

tile_x = pixel_x / tile_width
tile_y = pixel_y / tile_height

The runtime must keep the distinction clear. A character may use pixel coordinates for smooth motion while a tile layer uses integer tile coordinates for its layout. Converting at the boundary avoids comparing a player position in pixels with a map index in tiles.

Tilesets and Global IDs

A tileset is an image divided into equally sized tiles. A map layer stores global tile IDs (GIDs), not image coordinates. 0 means an empty cell. Other IDs identify a tile across every tileset used by the map.

Each tileset has a firstgid. The local tile ID is:

local_id = gid - firstgid
source_x = (local_id % columns_in_tileset) × tile_width
source_y = (local_id / columns_in_tileset) × tile_height

The modulo operation selects a column in the source image; integer division selects a row. A renderer uses those source coordinates to choose the correct tile image region and uses the layer position to choose the destination position. Treating a GID as a direct image index works only accidentally when a map has one tileset.

Ruby2D Tilesets

Ruby2D’s Tileset object is the rendering-side representation of a tileset image. It uses the same tile dimensions, padding, and spacing configured in Tiled. define_tile assigns a Ruby name to a source cell; set_tile places one or more copies at pixel positions.

terrain = Tileset.new(
  'assets/terrain.png',
  tile_width: 32,
  tile_height: 32,
  padding: 0,
  spacing: 0
)

terrain.define_tile('grass', 0, 0)
terrain.define_tile('stone', 1, 0)
terrain.set_tile('grass', [{ x: 0, y: 0 }, { x: 32, y: 0 }])
terrain.set_tile('stone', [{ x: 64, y: 0 }])

The Tileset object draws the image; Tiled does not automatically create it. A map loader is the bridge between Tiled’s GIDs and Ruby2D tile names and positions.

Layers as Runtime Contracts

Tiled supports tile layers, object layers, image layers, and groups. A tile layer is appropriate for terrain and decoration. An object layer is appropriate for entities and geometry that carry behaviour: spawn points, doors, enemies, pickups, checkpoints, and collision shapes.

Layer names act as an API between editor and code:

Background  → draw behind entities
Ground      → draw terrain
Collisions  → create solid rectangles
Entities    → create players, enemies, and pickups
Foreground  → draw in front of entities

Changing a layer name without updating the loader breaks that contract. Separating decorative tiles from collision data also prevents the renderer from becoming an accidental physics system.

TMJ and TMX Map Formats

A .tmj file is already a Tiled JSON map. The extension means “Tiled Map JSON”; it contains the map dimensions, layers, tilesets, tile IDs, objects, and properties as JSON. Ruby’s standard JSON library can parse it directly into nested hashes and arrays.

.tmx is the XML-based Tiled map format. Both files describe the same categories of map data, but they require different parsers: JSON.parse reads .tmj; an XML parser such as Ruby’s standard-library REXML reads .tmx. Renaming a .tmx file to .tmj does not convert its contents.

The Ruby2D loader can load a .tmj file directly:

require 'json'

map = JSON.parse(File.read('levels/level-1.tmj'))
tile_width = map['tilewidth']
collision_layer = map['layers'].find { |layer| layer['name'] == 'Collisions' }

The loader above expects .tmj JSON. A .tmx map needs either JSON export from Tiled or an XML-based loader.

An object-layer rectangle has x, y, width, and height in pixels. The loader can convert each one into the same rectangle representation used by the collision system:

solids = collision_layer['objects'].map do |object|
  { x: object['x'], y: object['y'], w: object['width'], h: object['height'] }
end

This keeps collision independent of tile art. A sloped-looking tile can remain decorative while a polygon or rectangle in the Collisions layer defines the actual playable surface.

Rendering a Single Tile Layer

A first loader can target one tileset and one Ground layer. The layer’s data array is row-major: every map['width'] entries form a row. each_with_index provides both a GID and its flat index; modulo recovers the column and integer division recovers the row.

GID_TO_TILE = { 1 => 'grass', 2 => 'stone' }

ground = map['layers'].find { |layer| layer['name'] == 'Ground' }

ground['data'].each_with_index do |gid, index|
  next if gid.zero?

  column = index % map['width']
  row = index / map['width']
  terrain.set_tile(GID_TO_TILE.fetch(gid), [{
    x: column * map['tilewidth'],
    y: row * map['tileheight']
  }])
end

GID_TO_TILE is intentionally explicit. With one tileset, its GIDs often begin at 1. Multiple tilesets require a firstgid offset before choosing the Ruby2D tile and source image. The explicit map makes the conversion visible before a general renderer introduces that extra layer of indirection.

Properties and Entities

Tiled objects and tiles can carry custom properties. A door object can have destination: "level-2"; an enemy spawn can have kind: "slime"; a tile can have solid: true. Properties describe data, not executable behaviour. Ruby code interprets them and instantiates the corresponding game object.

An entity layer therefore becomes a small declarative language for the game world. Adding an enemy is a matter of placing an object and setting its type; the loader turns that record into a Ruby object. The level can evolve without placing game rules directly inside the map file.

Camera and Map Space

Map data remains in world coordinates. A camera offset affects only drawing:

screen_x = world_x - camera_x
screen_y = world_y - camera_y

Collision rectangles, entity positions, and map objects do not change when the camera scrolls. This keeps a level stable while the viewport moves across it and provides the bridge from a fixed Snake board to a scrolling platform world.