A complete guide to integrating Tiled Map Editor with raylib-rs for 2D game development in Rust.
- Overview
- Prerequisites
- Setup
- Basic Implementation
- Understanding the Tiled API
- Complete Example
- Advanced Features
- Performance Optimization
- Common Pitfalls
- Resources
This guide shows you how to load and render Tiled maps in raylib using Rust. The tiled crate (v0.12+) provides excellent support for loading TMX files, and with some understanding of the API, you can render beautiful tile-based games.
Why This Combination?
- Tiled: Industry-standard, free tile map editor
- raylib: Simple, powerful game library with minimal dependencies
- Rust: Memory safety and performance
- Rust (2021 edition or later)
- Basic understanding of raylib and Rust
- Tiled Map Editor installed
Add to your Cargo.toml:
[dependencies]
raylib = "5.5" # or latest version
tiled = "0.12" # critical: 0.12+ has different API than earlier versions- Open Tiled and create a new map
- Set your tile size (e.g., 16x16, 32x32)
- Add a tileset (Image → New Tileset)
- Draw your map
- Save as
.tmxformat
Important: Place both your .tmx file and tileset images in your assets/ folder.
use raylib::prelude::*;
use std::collections::HashMap;
use tiled::{Loader, Map};
pub struct TiledMap {
map: Map,
tilesets: HashMap<usize, Texture2D>, // Map tileset index to texture
}impl TiledMap {
pub fn load(
rl: &mut RaylibHandle,
thread: &RaylibThread,
path: &str,
) -> Result<Self, tiled::Error> {
let mut loader = Loader::new();
let map = loader.load_tmx_map(path)?;
// Load tileset textures indexed by position
let mut tilesets = HashMap::new();
for (index, tileset) in map.tilesets().iter().enumerate() {
if let Some(image) = &tileset.image {
let texture_path = format!("assets/{}",
image.source.to_string_lossy());
match rl.load_texture(thread, &texture_path) {
Ok(texture) => {
tilesets.insert(index, texture);
}
Err(e) => {
eprintln!("Failed to load tileset: {}", e);
}
}
}
}
Ok(Self { map, tilesets })
}
}impl TiledMap {
pub fn draw(&self, d: &mut RaylibDrawHandle) {
let tile_width = self.map.tile_width as f32;
let tile_height = self.map.tile_height as f32;
for layer in self.map.layers() {
// Skip invisible layers
if !layer.visible {
continue;
}
// Only handle tile layers
let tile_layer = match layer.layer_type() {
tiled::LayerType::Tiles(tile_layer) => tile_layer,
_ => continue,
};
// Handle finite tile layers
match tile_layer {
tiled::TileLayer::Finite(finite_layer) => {
let width = finite_layer.width();
let height = finite_layer.height();
for y in 0..height {
for x in 0..width {
if let Some(layer_tile) = finite_layer.get_tile(x as i32, y as i32) {
self.draw_tile(d, layer_tile, x, y, tile_width, tile_height);
}
}
}
}
tiled::TileLayer::Infinite(_) => {
// Infinite layers not covered in this guide
eprintln!("Infinite layers not supported");
}
}
}
}
fn draw_tile(
&self,
d: &mut RaylibDrawHandle,
layer_tile: tiled::LayerTile,
x: u32,
y: u32,
tile_width: f32,
tile_height: f32,
) {
let tile_id = layer_tile.id();
// Skip empty tiles
if tile_id == 0 {
return;
}
// Get tileset and texture
let tileset = layer_tile.get_tileset();
let tileset_index = layer_tile.tileset_index();
let texture = match self.tilesets.get(&tileset_index) {
Some(tex) => tex,
None => return,
};
// Calculate source rectangle in tileset
let columns = tileset.columns;
let spacing = tileset.spacing as f32;
let margin = tileset.margin as f32;
let tsx = margin + (tile_id % columns) as f32 * (tile_width + spacing);
let tsy = margin + (tile_id / columns) as f32 * (tile_height + spacing);
let mut source = Rectangle::new(tsx, tsy, tile_width, tile_height);
// Handle tile flipping
if layer_tile.flip_h {
source.width = -source.width;
}
if layer_tile.flip_v {
source.height = -source.height;
}
// Draw to screen
let dest = Rectangle::new(
x as f32 * tile_width,
y as f32 * tile_height,
tile_width,
tile_height,
);
d.draw_texture_pro(
texture,
source,
dest,
Vector2::zero(),
0.0,
Color::WHITE,
);
}
}fn main() {
let (mut rl, thread) = raylib::init()
.size(800, 600)
.title("Tiled + Raylib")
.build();
let map = TiledMap::load(&mut rl, &thread, "assets/map.tmx")
.expect("Failed to load map");
while !rl.window_should_close() {
let mut d = rl.begin_drawing(&thread);
d.clear_background(Color::BLACK);
map.draw(&mut d);
}
}The tiled crate v0.12+ has a different API than older versions. Here are the critical differences:
Old versions (< 0.10) exposed first_gid directly. Version 0.12+ does not.
Instead, use LayerTile methods:
layer_tile.get_tileset()- Returns the&Tilesetfor this tilelayer_tile.tileset_index()- Returns the index in the map's tileset arraylayer_tile.id()- Returns the local tile ID within its tileset (not global GID)
Tileset: The tileset definition (columns, spacing, margin, image)Tile: A tile definition in a tileset (with properties, animations, etc.)LayerTile: An instance of a tile placed in a layer (has flip info, position)
The layer_tile.id() returns the local ID within the tileset, not the global GID from the TMX file.
This means:
- No need to subtract first_gid - the ID is already local
- Use
id % columnsto get the column - Use
id / columnsto get the row
// Get all tilesets
let tilesets: &[Arc<Tileset>] = map.tilesets();
// Iterate layers
for layer in map.layers() {
// layer.visible, layer.name, layer.opacity
match layer.layer_type() {
LayerType::Tiles(tile_layer) => { /* ... */ }
LayerType::Objects(object_layer) => { /* ... */ }
LayerType::Image(image_layer) => { /* ... */ }
LayerType::Group(group_layer) => { /* ... */ }
}
}Here's a complete, working example with camera controls:
mod tiled_map;
use raylib::prelude::*;
use tiled_map::TiledMap;
fn main() {
let (mut rl, thread) = raylib::init()
.size(1280, 720)
.title("Tiled Map Demo")
.build();
rl.set_target_fps(60);
// Load map
let map = TiledMap::load(&mut rl, &thread, "assets/map.tmx")
.expect("Failed to load map");
// Setup camera
let mut camera = Camera2D {
offset: Vector2::new(640.0, 360.0),
target: Vector2::new(0.0, 0.0),
rotation: 0.0,
zoom: 2.0,
};
let camera_speed = 300.0;
// Game loop
while !rl.window_should_close() {
let delta = rl.get_frame_time();
// Camera controls
if rl.is_key_down(KeyboardKey::KEY_W) {
camera.target.y -= camera_speed * delta / camera.zoom;
}
if rl.is_key_down(KeyboardKey::KEY_S) {
camera.target.y += camera_speed * delta / camera.zoom;
}
if rl.is_key_down(KeyboardKey::KEY_A) {
camera.target.x -= camera_speed * delta / camera.zoom;
}
if rl.is_key_down(KeyboardKey::KEY_D) {
camera.target.x += camera_speed * delta / camera.zoom;
}
// Zoom
let wheel = rl.get_mouse_wheel_move();
if wheel != 0.0 {
camera.zoom += wheel * 0.5;
camera.zoom = camera.zoom.clamp(0.5, 4.0);
}
// Draw
let mut d = rl.begin_drawing(&thread);
d.clear_background(Color::BLACK);
{
let mut d2d = d.begin_mode2D(camera);
map.draw(&mut d2d);
}
d.draw_fps(10, 10);
d.draw_text(
&format!("Zoom: {:.1}x", camera.zoom),
10,
30,
20,
Color::WHITE,
);
}
}Only draw tiles visible on screen:
pub fn draw_culled(
&self,
d: &mut RaylibDrawHandle,
camera: Camera2D,
screen_width: f32,
screen_height: f32,
) {
let tile_width = self.map.tile_width as f32;
let tile_height = self.map.tile_height as f32;
for layer in self.map.layers() {
if !layer.visible {
continue;
}
let tile_layer = match layer.layer_type() {
tiled::LayerType::Tiles(tl) => tl,
_ => continue,
};
match tile_layer {
tiled::TileLayer::Finite(finite_layer) => {
let width = finite_layer.width();
let height = finite_layer.height();
// Calculate visible tile range
let start_x = ((camera.target.x - screen_width / (2.0 * camera.zoom))
/ tile_width).floor().max(0.0) as u32;
let end_x = ((camera.target.x + screen_width / (2.0 * camera.zoom))
/ tile_width).ceil().min(width as f32) as u32;
let start_y = ((camera.target.y - screen_height / (2.0 * camera.zoom))
/ tile_height).floor().max(0.0) as u32;
let end_y = ((camera.target.y + screen_height / (2.0 * camera.zoom))
/ tile_height).ceil().min(height as f32) as u32;
// Only draw visible tiles
for y in start_y..end_y {
for x in start_x..end_x {
if let Some(layer_tile) = finite_layer.get_tile(x as i32, y as i32) {
self.draw_tile(d, layer_tile, x, y, tile_width, tile_height);
}
}
}
}
tiled::TileLayer::Infinite(_) => {
eprintln!("Infinite layers not supported in culled rendering");
}
}
}
}Use object layers for collision, spawn points, etc:
pub fn get_objects_from_layer(&self, layer_name: &str) -> Vec<MapObject> {
let mut objects = Vec::new();
for layer in self.map.layers() {
if layer.name != layer_name {
continue;
}
if let tiled::LayerType::Objects(object_layer) = layer.layer_type() {
for object in object_layer.objects() {
// Get dimensions from shape
let (width, height) = match &object.shape {
tiled::ObjectShape::Rect { width, height } => (*width, *height),
tiled::ObjectShape::Ellipse { width, height } => (*width, *height),
_ => (0.0, 0.0),
};
objects.push(MapObject {
position: Vector2::new(object.x, object.y),
size: Vector2::new(width, height),
object_type: object.user_type.clone(),
name: object.name.clone(),
});
}
}
}
objects
}
#[derive(Debug, Clone)]
pub struct MapObject {
pub position: Vector2,
pub size: Vector2,
pub object_type: String,
pub name: String,
}// In draw_tile function, use layer opacity:
let alpha = (layer.opacity * 255.0) as u8;
let tint = Color::new(255, 255, 255, alpha);
d.draw_texture_pro(
texture,
source,
dest,
Vector2::zero(),
0.0,
tint, // Use layer opacity
);// Get tile definition for animations
if let Some(tile) = layer_tile.get_tile() {
if let Some(animation) = &tile.animation {
// animation contains frames with durations
// Implement frame switching based on elapsed time
}
}- Use Culled Rendering: Only draw visible tiles (see above)
- Batch Draw Calls: Group tiles by texture when possible
- Cache Static Layers: Render static layers to a
RenderTexture2Donce - Limit Layers: Keep layer count reasonable (< 10)
- Optimize Tileset: Use power-of-2 textures, combine small tilesets
// One-time render of static background layer
let mut static_texture = rl.load_render_texture(&thread,
map_width as u32, map_height as u32).unwrap();
{
let mut d = rl.begin_texture_mode(&thread, &mut static_texture);
d.clear_background(Color::BLANK);
map.draw_layer(&mut d, 0); // Draw layer 0 only
}
// In game loop, just draw the cached texture
d.draw_texture(&static_texture, 0, 0, Color::WHITE);// DON'T DO THIS (doesn't exist in v0.12+)
let first_gid = tileset.first_gid; // ERROR!Fix: Use layer_tile.id() which returns the local ID.
// Map says: <image source="tileset.png" />
// You need: assets/tileset.pngFix: Ensure tileset images are in assets/ and paths are relative to TMX file.
If your tiles look offset or wrong, check:
tileset.spacing- Pixels between tilestileset.margin- Pixels around the edge- These are set in Tiled when creating the tileset
// Always check for empty tiles (id 0)
if tile_id == 0 {
continue;
}Make sure you're handling flip flags:
if layer_tile.flip_h {
source.width = -source.width;
}
if layer_tile.flip_v {
source.height = -source.height;
}// Layers is an iterator, not a slice
// This won't work:
let layer = self.map.layers()[0]; // ERROR
// Do this instead:
let layer = self.map.layers().nth(0).unwrap();- Tile Layers: Main visual layers
- Object Layers: Collision, spawns, triggers
- Custom Properties: Add metadata to tiles, objects, layers
- Tile Animations: Built-in animation support
- Wang Sets: For auto-tiling
Key takeaways:
- Use
tiledcrate v0.12+ (different API from older versions) layer_tile.id()gives you the local tile ID (not global GID)- Use
layer_tile.get_tileset()andlayer_tile.tileset_index()to get tileset info - Always handle empty tiles, flipping, spacing, and margin
- Use culled rendering for large maps
- Object layers are great for game logic data
Happy mapping! If you have questions or improvements, feel free to contribute to this guide.
License: CC0 / Public Domain - Use freely!