SpriteForge.tech
Blog Pricing Open the studio

How to use a 16-tile Wang tileset in Godot 4: terrains and the dual grid

← All posts · · 8 min read
TL;DR
  • 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.

A tile split into four corners worth 1, 2, 4 and 8, and the sixteen tiles of the sheet laid out in mask order NW 1 NE 2 SW 4 SE 8 one tile, four corners NW + SE = 1 + 8 = 9 so this is tile 9 the 64×64 sheet: index = mask 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Corner values NW=1, NE=2, SW=4, SE=8. Tile 0 is empty, tile 15 is solid snow.

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:

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

  1. Add a TileMapLayer node. (The older TileMap node is deprecated in favour of one TileMapLayer per layer.)
  2. 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.
  3. Open the TileSet panel at the bottom of the editor and drag snow_autotile.png onto 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.

TilesSnow on these corners
1, 2, 4, 8Only NW, NE, SW or SE respectively
3, 12Top pair (NW+NE), bottom pair (SW+SE)
5, 10Left pair (NW+SW), right pair (NE+SE)
6, 9The diagonals: NE+SW, NW+SE
7, 11, 13, 14All but SE, SW, NE or NW respectively
15All 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:

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:

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.

The display grid sits half a tile off the data grid, so each display tile covers the corners of four data cells filled: snow cells (data) · dashed: display tiles the outlined display tile (x, y) reads: NW (x-1, y-1) = 1 NE (x, y-1) = 2 SW (x-1, y) = 4 SE (x, y) = 8 y points down in Godot; here only SE is snow, so it gets tile 8
The display grid (dashed) is offset by half a tile. Display cell (x, y) is drawn over the point where data cells (x−1, y−1), (x, y−1), (x−1, y) and (x, y) meet.

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:

  1. Blurry tiles: the Default Texture Filter is still Linear.
  2. Snow shifted by half a tile: the Display layer's Position isn't (-8, -8).
  3. 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.