How to use a 16-tile Wang tileset in Godot 4: terrains and the dual grid
- A 16-tile corner set has one tile per combination of four corners. The tile's index is its corner mask: NW=1, NE=2, SW=4, SE=8.
- Godot's terrain painter handles it in Match Corners mode. Give each tile its four corner peering bits (a short editor script below does all fifteen).
- Or skip terrains and use a dual grid: a display layer offset by half a tile that picks each tile from the four data cells around it. About 30 lines of GDScript.
A Wang tileset for one terrain on empty ground (snow on nothing, path on grass) is sixteen tiles, one for every way the four corners of a tile can be filled. This tutorial puts one into Godot 4 twice: first with Godot's built-in terrain painting, then with the dual-grid approach that many 2D games use instead. Everything here was checked against the Godot 4 documentation; the scripts use the TileMapLayer node, which needs Godot 4.3 or newer.
To follow along, grab SpriteForge's free Snow Starter Pack on itch.io. You need two files from it: snow_autotile.png, a 64×64 sheet of sixteen 16×16 tiles, and snow_autotile.json, which lists each tile's rect and corners.
The set: one tile per corner combination
Each tile is split into four corners, and each corner is either snow or empty. Give the corners the values 1, 2, 4 and 8, add up the snowy ones, and you get a number from 0 to 15. That number is the tile's corner mask, and in this sheet it is also the tile's position: the tiles are laid out in mask order, left to right, top to bottom.
So tile m sits at atlas column m % 4, row m / 4. Tile 9 (NW and SE) is at column 1, row 2. The JSON says the same thing in data form:
{ "mask": 9, "x": 16, "y": 32, "w": 16, "h": 16,
"corners": { "NW": true, "NE": false, "SW": false, "SE": true } }
This is the dual-grid convention described in why AI tilesets don't tile: the corners of a tile are the points between map cells, which is why the snow pattern can carry across tile edges instead of repeating as a stamp.
1. Import the sheet without blurring
Copy snow_autotile.png into your project. In Godot 4, filtering is not an import option for 2D textures: it is set on the node that draws them, with a project-wide default. For a pixel-art project, set the default once:
- Project → Project Settings → Rendering → Textures → Canvas Textures → Default Texture Filter:
Nearest. The default is Linear, which smears 16 px tiles. - Import dock: leave the defaults. Godot's docs note that the default import options (no mipmaps, Lossless compression) suit 2D, and Lossless is the setting they recommend for pixel art.
If you only want one node to be crisp, set its texture filter to Nearest in the Inspector instead (the CanvasItem.texture_filter property).
2. Create the TileSet and the atlas
- Add a TileMapLayer node. (The older
TileMapnode is deprecated in favour of one TileMapLayer per layer.) - In the Inspector, create a New TileSet in its Tile Set property. Check that Tile Size is 16×16 (the default) before adding the texture: Godot uses it to cut the atlas automatically.
- Open the TileSet panel at the bottom of the editor and drag
snow_autotile.pngonto it. When Godot asks whether to create tiles automatically, answer Yes.
Tile 0 is fully transparent, so Godot may leave that cell of the atlas without a tile. That's fine: nothing below ever places tile 0.
3. Add a terrain set in Match Corners mode
Select the TileMapLayer, and in the Inspector, inside the TileSet resource, add an element under Terrain Sets. Set its Mode to Match Corners ("requires corners to match with neighboring tiles' terrains" in the class reference), then add one terrain to it and name it Snow. Terrain set IDs and terrain IDs both start at 0, so this is terrain set 0, terrain 0.
Why Match Corners and not Match Corners and Sides? The sixteen tiles only describe corners. Match Corners and Sides also compares the sides, which needs a larger set than sixteen corner tiles.
4. Assign the peering bits
The peering bits tell Godot what is at each corner of a tile. In the TileSet panel, switch to Select, click a tile, unfold Terrains, set Terrain Set to 0 and Terrain to 0 (Godot needs the terrain set first), then set the four corner bits: Snow on the corners that are in the tile's mask, empty on the others.
| Tiles | Snow on these corners |
|---|---|
| 1, 2, 4, 8 | Only NW, NE, SW or SE respectively |
| 3, 12 | Top pair (NW+NE), bottom pair (SW+SE) |
| 5, 10 | Left pair (NW+SW), right pair (NE+SE) |
| 6, 9 | The diagonals: NE+SW, NW+SE |
| 7, 11, 13, 14 | All but SE, SW, NE or NW respectively |
| 15 | All four |
Fifteen tiles by hand is a good way to make one mistake. Because the index is the mask, a short EditorScript can do it. Save this as a .gd file, select the TileMapLayer in the scene tree, open the script in the Script editor and run it with File → Run (Ctrl+Shift+X):
@tool
extends EditorScript
# Assigns Match Corners peering bits to a 16-tile Wang set in mask order.
# Select the TileMapLayer that uses the snow TileSet, then File > Run.
func _run() -> void:
var layer := EditorInterface.get_selection().get_selected_nodes()[0] as TileMapLayer
var ts := layer.tile_set
if ts.get_terrain_sets_count() == 0:
ts.add_terrain_set()
ts.set_terrain_set_mode(0, TileSet.TERRAIN_MODE_MATCH_CORNERS)
ts.add_terrain(0)
ts.set_terrain_name(0, 0, "Snow")
var src := ts.get_source(ts.get_source_id(0)) as TileSetAtlasSource
for mask in range(1, 16): # tile 0 stays without terrain
var data := src.get_tile_data(Vector2i(mask & 3, mask >> 2), 0)
data.terrain_set = 0
data.terrain = 0
data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, _bit(mask, 1))
data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER, _bit(mask, 2))
data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, _bit(mask, 4))
data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER, _bit(mask, 8))
EditorInterface.mark_scene_as_unsaved()
func _bit(mask: int, b: int) -> int:
return 0 if (mask & b) != 0 else -1 # 0 = Snow, -1 = empty
It creates the terrain set if there isn't one yet, assumes Snow is terrain 0 of terrain set 0 and that the snow sheet is the TileSet's first source, and then sets every tile from its own index. Save the scene afterwards (the TileSet is saved with it, or with its own .tres if you saved it separately).
5. Paint with the terrain
Select the TileMapLayer, open the TileMap panel at the bottom and switch to its Terrains tab. Pick terrain set 0 and Snow, then paint:
- Connect mode connects what you paint to the surrounding tiles on the same layer. Use this for areas of snow.
- Path mode only connects tiles painted in the same stroke, so two paths can run side by side without merging.
- The Eraser combines with either mode.
Only tiles with at least one bit set to Snow show up in the terrain's tile list, which is a quick check that the script worked: you should see fifteen tiles there. If a corner ever looks wrong after painting, select that tile in the TileSet panel and compare its four corner bits with the table above.
The dual-grid alternative
Godot's terrain painter works on the same grid you paint on, and it has to solve which tile fits every cell. The dual grid sidesteps that. You keep two layers:
- a data layer where you only mark which cells are snow (any tile will do; it is hidden at runtime), and
- a display layer using the snow TileSet, moved up and left by half a tile, whose every cell sits on a corner where four data cells meet.
Each display cell looks at the four data cells around its centre and places the tile with that mask. There's no solving and no ambiguity, and it's exactly how SpriteForge composes its own previews.
Scene setup: two TileMapLayer nodes, World and Display, both using the snow TileSet. Set Display's Transform → Position to (-8, -8), half of the 16 px tile. Paint snow on World with tile 15 (or any tile). Then attach this script to their parent and assign both layers in the Inspector:
extends Node2D
@export var world: TileMapLayer # data: any tile = snow. Hidden at runtime.
@export var display: TileMapLayer # snow TileSet, Position (-8, -8)
@export var source_id := 0 # the snow atlas in the TileSet
func _ready() -> void:
world.visible = false
refresh()
func refresh() -> void:
display.clear()
for cell in world.get_used_cells():
_update_around(cell)
# Change one data cell at runtime and fix the four display cells it touches.
func set_snow(cell: Vector2i, on: bool) -> void:
if on:
world.set_cell(cell, source_id, Vector2i(3, 3)) # tile 15
else:
world.erase_cell(cell)
_update_around(cell)
func _has(cell: Vector2i) -> bool:
return world.get_cell_source_id(cell) != -1
# Data cell (x, y) is a corner of display cells (x, y), (x+1, y), (x, y+1), (x+1, y+1).
func _update_around(cell: Vector2i) -> void:
for v in [cell, cell + Vector2i(1, 0), cell + Vector2i(0, 1), cell + Vector2i(1, 1)]:
var mask := 0
if _has(v + Vector2i(-1, -1)): mask |= 1 # NW
if _has(v + Vector2i(0, -1)): mask |= 2 # NE
if _has(v + Vector2i(-1, 0)): mask |= 4 # SW
if _has(v): mask |= 8 # SE
if mask == 0:
display.erase_cell(v)
else:
display.set_cell(v, source_id, Vector2i(mask & 3, mask >> 2))
Two details matter. Godot's y axis points down, so the cells above a display cell have the smaller y (NW is (x−1, y−1)). And the atlas coordinate comes straight from the mask, (mask % 4, mask / 4), written with bit operations so GDScript doesn't warn about integer division.
Which to use? The terrain painter keeps everything in the editor and is fine for hand-built levels. The dual grid is better when the map changes at runtime or comes from code (procedural levels, digging, spreading snow), because one call to set_snow() updates exactly four tiles. Your game logic reads the data layer; the display layer is only pictures.
Quick check if something looks off:
- Blurry tiles: the Default Texture Filter is still Linear.
- Snow shifted by half a tile: the Display layer's Position isn't
(-8, -8). - Corners swapped top to bottom: NW/NE and SW/SE are mixed up, usually from code written for a y-up engine.
SpriteForge generates tilesets in exactly this format: sixteen tiles in mask order with the JSON beside them. See the tileset generator.