TileMapLayer: a level, coordinates and cell edits
Run a map with an actor, edit walls and understand binary save limits.
Run the level
Use Reckfell.NET.Sdk 1.0.0, net10.0 and Compatibility rendering. Save the three files below, run dotnet build, create a level with an Entity2D root and attach TileLevel.cs. Save tiles.rscene and run that level.
Arrow keys move the white actor. A left click toggles an interior cell between floor and wall; border cells are protected from this toggle. Standard bindings use ui_left/ui_right/ui_up/ui_down. Focus the game window.
The script creates a 20×15 map, TileSet and actor. Cells occupy 16×16 units and the game window uses a logical 320×240 size during execution. Do not add a second set of map/bodies manually. This demonstrates map edits rather than a production game editor: inserting collision inside the actor needs a separate overlap-resolution policy.
ExampleTileSet.cs
using Reckfell;
public static class ExampleTileSet
{
public const int SourceId = 0;
public static readonly Vector2I Floor = new(0, 0);
public static readonly Vector2I Wall = new(1, 0);
public static TileSet Create()
{
using var image = Image.CreateEmpty(32, 16, false, Image.Format.Rgba8);
image.Fill(new Color(0.12f, 0.14f, 0.18f));
image.FillRect(new Rect2I(1, 1, 14, 14), new Color(0.17f, 0.19f, 0.23f));
image.FillRect(new Rect2I(16, 0, 16, 16), new Color(0.2f, 0.38f, 0.9f));
image.FillRect(new Rect2I(17, 1, 14, 3), new Color(0.4f, 0.57f, 1));
var atlas = new TileSetAtlasSource
{
Texture = ImageTexture.CreateFromImage(image),
TextureRegionSize = new Vector2I(16, 16)
};
var tiles = new TileSet { TileSize = new Vector2I(16, 16) };
tiles.AddPhysicsLayer();
tiles.SetPhysicsLayerCollisionLayer(0, 1);
tiles.SetPhysicsLayerCollisionMask(0, 1);
tiles.AddCustomDataLayer();
tiles.SetCustomDataLayerName(0, "walkable");
tiles.SetCustomDataLayerType(0, Variant.Type.Bool);
tiles.AddSource(atlas, SourceId);
atlas.CreateTile(Floor);
atlas.CreateTile(Wall);
atlas.GetTileData(Floor, 0).SetCustomData("walkable", true);
var wall = atlas.GetTileData(Wall, 0);
wall.SetCustomData("walkable", false);
wall.SetCollisionPolygonsCount(0, 1);
wall.SetCollisionPolygonPoints(0, 0, new[]
{
new Vector2(-8, -8), new Vector2(8, -8),
new Vector2(8, 8), new Vector2(-8, 8)
});
return tiles;
}
}TileWalker.cs
using Reckfell;
public partial class TileWalker : CharacterBody2D
{
public override void _Ready()
{
MotionMode = MotionModeEnum.Floating;
AddChild(new CollisionShape2D { Shape = new CircleShape2D { Radius = 6 } });
}
public override void _PhysicsProcess(double delta)
{
Velocity = Input.GetVector("ui_left", "ui_right", "ui_up", "ui_down") * 120;
MoveAndSlide();
}
public override void _Draw() => DrawCircle(Vector2.Zero, 6, Colors.White);
}TileLevel.cs
using Reckfell;
public partial class TileLevel : Entity2D
{
public TileMapLayer Map { get; private set; } = null!;
public TileWalker Player { get; private set; } = null!;
public override void _Ready()
{
if (GetViewport() is Window window)
{
window.ContentScaleSize = new Vector2I(320, 240);
window.ContentScaleMode = Window.ContentScaleModeEnum.Viewport;
}
Map = new TileMapLayer { TileSet = ExampleTileSet.Create(), TextureFilter = TextureFilterEnum.Nearest };
AddChild(Map);
for (int y = 0; y < 15; y++)
for (int x = 0; x < 20; x++)
{
bool wall = x == 0 || x == 19 || y == 0 || y == 14 || (x == 12 && y >= 3 && y <= 11);
Map.SetCell(new Vector2I(x, y), ExampleTileSet.SourceId, wall ? ExampleTileSet.Wall : ExampleTileSet.Floor);
}
Map.UpdateInternals();
Player = new TileWalker { Position = Map.MapToLocal(new Vector2I(5, 7)) };
AddChild(Player);
}
public bool ToggleCell(Vector2I cell)
{
if (cell.X <= 0 || cell.X >= 19 || cell.Y <= 0 || cell.Y >= 14) return false;
Vector2I next = Map.GetCellAtlasCoords(cell) == ExampleTileSet.Wall ? ExampleTileSet.Floor : ExampleTileSet.Wall;
Map.SetCell(cell, ExampleTileSet.SourceId, next);
return true;
}
public override void _UnhandledInput(InputEvent input)
{
if (input is InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left })
ToggleCell(Map.LocalToMap(Map.GetLocalMousePosition()));
}
}Choose the correct coordinates
| Value | Example | Meaning |
|---|---|---|
| Cell coordinate | (2,3) | Integer grid position |
| MapToLocal((2,3)) | (40,56) | Cell center local to the layer |
| ToGlobal(MapToLocal(cell)) | Depends on layer Transform | Canvas-space center position |
| LocalToMap(localPoint) | For example (−1,−1) for point (−1,−1) | Convert a local point to a cell |
Use LocalToMap(ToLocal(position)) for a canvas-space position. Do not directly pass InputEvent.Position: it belongs to Viewport space, affected by camera and stretching. The sample GetLocalMousePosition obtains the appropriate layer-local point.
Integer-casting a division does not replace LocalToMap. It rounds negative values differently, while isometric and hexagonal layouts also require TileSet rules. MapToLocal returns the cell center, not necessarily the artwork center when TextureOrigin offsets it.
Place, replace or remove a tile
SetCell takes cell coordinates, sourceId, atlasCoords and alternativeTile. For this atlas use sourceId=0, Floor=(0,0) or Wall=(1,0), and alternativeTile=0. Map coordinates are separate from atlas coordinates.
Importantly, SetCell(cell, 0) does not select the first tile. Omitting atlasCoords supplies invalid (−1,−1), clearing the cell. Use EraseCell for explicit removal. Identifiers must match the assigned TileSet.
GetCellSourceId/GetCellAtlasCoords reflect logical changes immediately. Rendering, physics and other internal batches update later. Call UpdateInternals once after a group of edits if needed; not once per cell inside a loop. It does not advance physics simulation by an extra tick.
In these actual frames the actor first stops against the blue wall, then passes through a cell replaced by floor. Tile rendering and collision do not create a separate entity for every cell.
Wall closed
Wall replaced by floor
Read map data
GetUsedCells returns occupied cells. GetUsedRect bounds them in grid coordinates: our level has position (0,0) and size (20,15), not (320,240). Empty cells are excluded.
GetCellTileData works with atlas tiles and may return null for an empty cell or another source type. The data belongs to the TileSet tile rather than an independent cell instance. Store doors, destruction and other level state in your own coordinate-indexed structure or deliberately replace a cell tile reference.
Saving and large coordinates
TileMapData contains binary cell references rather than the TileSet or textures themselves. Restoration needs the matching resource identifiers.
The verified format stores signed 16-bit coordinates: the safe serialized range on each axis is −32768 through 32767. Runtime cell (40000,0) became (−25536,0) after this roundtrip. A Vector2I API does not mean its entire range survives the binary format.
Do not treat this array as an uncontrolled cross-version save format. Define your own schema version and stable gameplay identifiers for long-term persistence. Large worlds need region design with small local coordinates; this example does not implement automatic region streaming.
Diagnose incorrect behavior
- Visible tile without collision: check the TileSet physics layer, tile polygon and body masks.
- Cell disappears instead of receiving a tile: supply atlasCoords explicitly and verify source/alternative existence.
- Click edits a neighboring cell: check coordinate space and layer transform.
- Saved cell moves far away: check binary coordinate range.
- Editing stalls frames: batch SetCell calls rather than rebuilding after each cell.