Tilemaps
A tilemap is a grid of tiles, where each tile normally represents a part of the world like a piece of grass, a wall, or a patch of ocean. Tilemaps are an efficient way to build levels in 2D games, whether they be the levels of a platformer, the rooms of a dungeon crawler, or the tiles of a real-time strategy game.
In Easel, tilemaps are be built by composing a number of features together to suit your needs:
- Tiled Sprites let you display images in a grid.
For example, you could have a
TilemapSpritethat displays the grass, dirt and water tiles of a farm. - Tiled Colliders allow tiles to physically interact with other objects.
For example, you could have a
TilemapColliderrepresenting walls that block the player's movement. - Spritesheets allow you to store all the images of a tileset in a single image file, and then reference each image by its index in the spritesheet. For example, you might be building a dungeon crawler and have a spritesheet containing images for stone floors, brick walls, wooden doors, as well as a variety of props like barrels, crates and torches.
- Arrays and Maps: Arrays or Maps can be used to store the state of each tile. For example, in a mining game, you might have an Array representing the quantity of rock in each tile, and as the player excavates the rock you would reduce the count in each tile.
- Signals can be used to notify when a tile has changed,
so that the
TilemapSpriteandTilemapCollidercan be updated accordingly. - Data Files: The level itself may be authored using an external tool like Tiled
and then imported as
JSON. This lets you use the full power of a dedicated level editor to design your levels, and then use Easel to turn it into a multiplayer game.
Minimal Example
pub const Level1Data = [
[ 1, 0, 0, 0, 0 ],
[ 1, 0, 0, 0, 0 ],
[ 1, 0, 1, 0, 0 ],
[ 1, 0, 1, 1, 0 ],
[ 1, 0, 0, 0, 0 ],
[ 1, 1, 1, 1, 1 ],
]
pub game fn World.Main() {
Spawn walls {
use body=this
Body(pos=@(0,0), immovable=true) // immovable=true is a more performant
// Create the tilemaps
TilemapSprite(layer=-10)
TilemapCollider(category=Category:Default)
// Fill the tilemaps with data
for row, rowData in Level1Data {
for col, isSolid in rowData {
if !isSolid { continue }
TileSprite(coords=@(col, row), image=@brick.png)
TileCollider(coords=@(col, row), collide=true)
}
}
}
}
- The TilemapSprite and TilemapCollider
functions are used to create the tilemaps, defining any options that apply to all tiles like their
body,layerorcategory. - The TileSprite and TileCollider functions are used to set the value of a particular tile.
- The
immovable=trueparameter onBodyallows the physics engine to skip some calculations for better performance, and this is especially important for a TilemapCollider which can be very large and complex.
Tilemap vs World Coordinates
A tilemap is a grid of tiles, where each tile has a column and row index. By default, the origin tile (column 0, row 0) will be centered on the body's position. Tiles can be given a positive or a negative column and row number, so the tilemap can extend in all directions from the origin tile.
Tilemaps are easiest to work with when the tile and world coordinates are the same,
and this is the way Easel is designed to work by default.
Simply attach your tilemap to a Body of position @(0,0) and the tile and world coordinates will be the same,
as long as you don't change any other parameters like tileSize or bodyOffset.
Attach your tilemaps to a body such as Body(pos=@(0,0), immovable=true) to make tile and world coordinates the same.
It means tile at column 3, row 2 will be at world position @(3, 2), making tilemaps easier to work with.
Example
When using the recommended value of Body(pos=@(0,0)) and
leaving the defaults tileSize=1, bodyOffset=@(0,0) and angleOffset=0rev unchanged:
- The tile at column 3, row 2 is at world position
@(3, 2). This means you can simply place an object at@(3, 2)in world coordinates, and it will sit at the center of that tile. - The origin tile (column 0, row 0) is at world position
@(0, 0). It covers the rectangle from@(-0.5, -0.5)to@(0.5, 0.5)in world coordinates.
Converting between tile and world coordinates
When using the recommended values of Body(pos=@(0,0)) and tileSize=1,
the only thing you may need to do when converting from world coordinates to tile coords is round the world coordinates to the nearest integer.
You can do this by simply using the Round function:
let pos = @(2.3, 3.4)
let tilePos = pos.Round // tilePos is @(2, 3)
Populating a Tilemap
Once you have created a tilemap, the next step is to populate it with data. This requires using the corresponding per-tile function:
- For TilemapSprite, use TileSprite to set the image for a particular tile.
- For TilemapCollider, use TileCollider to set the collision state for a particular tile.
Dense Storage
Tilemaps are stored as a dense grid of tiles. This allows the rendering and physics engines to be very efficient as they can jump directly to the subset of data they need. Whenever you add a tile outside the current bounds of the tilemap, the tilemap will automatically grow to include that tile.
If you have some tiles that are far away from the others, there will be a lot of wasted space in the tilemap. Consider using multiple tilemaps to store different parts of your level if you have a sparse layout.
Maximum Dimensions
Tilemap column and row indexes are limited to the range -32768 to 32767.
Multiple Tilemaps
Unlike other sprite functions like ImageSprite,
if you want to have more than one TilemapSprite on the same entity, you must give each
its own <Id> parameter manually.
By default, one entity can only have one TilemapCollider and one TilemapSprite.
To have multiple, you must give them an ID:
TilemapSprite<ground>(layer=-10)
TileSprite<ground>(coords=@(2, 3), image=@grass.png)
TilemapSprite<walls>(layer=10)
TileSprite<walls>(coords=@(4, 6), image=@brick.png)
You can always also spawn multiple entities, each with their own tilemaps, to achieve the same effect:
Spawn ground {
TilemapSprite(layer=-10)
TileSprite(coords=@(2, 3), image=@grass.png)
}
Spawn walls {
TilemapSprite(layer=10)
TileSprite(coords=@(4, 6), image=@brick.png)
}
Arrays or Maps: Tile State
While TilemapSprite and TilemapCollider can store information specific to the rendering or physics of each tile,
often you will store your primary tile state in an Array
or Map,
and then sync them to the TilemapSprite and TilemapCollider as needed.
For example, in a mining game, you might have an Array where 1 represents the presence of rock and 0 represents empty space.
As the player excavates the rock you would update the tiles from 1 to 0 in your Array,
and then sync the changes to the TilemapSprite and TilemapCollider.
Here are some strategies you could use for storing this data.
Nested arrays
This strategy involves one Array for each row, nested inside a parent Array for the whole level:
pub let LevelData = [
[ 1, 0, 0, 0, 0 ],
[ 1, 0, 0, 0, 0 ],
[ 1, 0, 1, 0, 0 ],
[ 1, 0, 1, 1, 0 ],
[ 1, 0, 0, 0, 0 ],
[ 1, 1, 1, 1, 1 ],
]
How to read the tile at column 2, row 3:
let x = LevelData[3][2] // note, row goes first
How to iterate over the whole level:
for row, rowData in LevelData {
for col, isSolid in rowData {
TileSprite(coords=@(col, row), image=@grass.png)
}
}
Pros and cons of this strategy:
- Easy to work with because the columns and rows correspond directly to Array indexes.
- Cannot store negative column or row indexes, because Array indexes must be non-negative.
- If your data is changing a lot, this format is the most efficient when it comes to Easel's rollback netcode multiplayer architecture, because if any cell changes, only the changed row Array needs to be snapshotted, not the entire level.
Single flat array, with width variable
This strategy involves a single Array for the whole level. A separate variable is needed to store the width of the level, in order to be able to convert back from a single Array index to a column and row index.
pub let LevelWidth = 5
pub let LevelData = [
1, 0, 0, 0, 0,
1, 0, 0, 0, 0,
1, 0, 1, 0, 0,
1, 0, 1, 1, 0,
1, 0, 0, 0, 0,
1, 1, 1, 1, 1,
]
How to read the tile at column 2, row 3:
let x = LevelData[3 * LevelWidth + 2]
How to iterate over the whole level:
for index, isSolid in LevelData {
let col = index % LevelWidth
let row = Floor(index / LevelWidth)
TileSprite(coords=@(col, row), image=@grass.png)
}
Pros and cons of this strategy:
- More involved because have to store the width of the level separately, and calculate the row and column indexes when accessing a tile.
- This format is the most efficient in terms of memory usage as it packs in the data as tightly as possible.
- If your data is changing a lot, the entire array must be snapshotted for rollback netcode multiplayer, even if only a single cell changes. This is still quite fast because it is a linear array, but not as fast as the nested array strategy above.
Map with Vector keys
This strategy involves a single Map for the whole level, with the column and row indexes stored together as a single Vector key. Only the tiles that are actually present are stored in the Map, so it is a sparse representation of the level.
pub let LevelData = {
@(0, 0) = 1,
@(0, 1) = 1,
@(0, 2) = 1, @(2, 2) = 1,
@(3, 0) = 1, @(3, 2) = 1, @(3, 3) = 1,
@(4, 0) = 1,
@(5, 0) = 1, @(5, 1) = 1, @(5, 2) = 1, @(5, 3) = 1, @(5, 4) = 1,
}
How to read the tile at column 2, row 3:
// Use a Vector as the key to access the Map
let x = LevelData[@(2, 3)]
How to iterate over the whole level:
for coords, isSolid in LevelData {
TileSprite(coords=, image=#bb00ff)
}
Pros and cons of this strategy:
- If the data is sparse, this format is the most efficient in terms of memory usage, as it only stores the tiles that are actually present, and does not waste space on empty tiles.
- Can iterate over only the tiles that are present, rather than having to iterate over every tile in a dense grid, which is much faster.
Signaling Changes to the Tile State
As your player is excavating the rock, you will be updating your Array or Map to reflect the new state of the level.
How do you synchronize this back to the TileSprite and TileCollider,
so that the visual and physical representation of the level is updated?
- Use a Signal to notify when a tile has changed,
- Hook to the signal with an
onblock to update theTileSpriteandTileCollideraccordingly. - Declare a function like
ExcavateTile(pos)that updates yourArrayorMapand then emits the signal.
pub let LevelData = [...] // see above for an example of how to populate this
pub signal LevelChanged(coords)
pub fn ExcavateTile(coords) {
if !LevelData[coords.Y][coords.X] { return } // already excavated
LevelData[coords.Y][coords.X] = 0 // update the data
LevelChanged(coords) // signal that a tile has been changed
}
on LevelChanged coords {
let hasRock = LevelData[pos.Y][pos.X]
TileSprite(coords=, image=(hasRock ? @rock.png : @no-rock.png))
TileCollider(coords=, collide=hasRock)
}
This is in line with Easel's philophy of reactive programming,
where the state lives in your code, not the engine.
Your Array or Map is the source of truth,
and from there you derive the visual and physical representation of the level
by performing a one-way sync from your Array/Map to the TileSprite and TileCollider.
Importing Tilemaps from External Tools
You may want to use an external tool like Tiled or LDtk to design your levels,
and then import them into Easel.
To do this, you can export your level from the external tool as a JSON or CSV file,
and then use the Data Files feature to import the data into your Easel project.
And it's not just level data - you can import custom fields, paths, markers,
and all other forms of data you can author in those tools.