Game Dev Artisan Logo
In this post we’re going to continue our series on Godot Fundamentals. We’ll be covering the TileMap and TileSet features within Godot and how you can setup Terrains, Layers, Auto-Tiling, Patterns, and Custom Data for your Levels. By the end we should be able to design our Level using the our TileSet, and see our Tank behave differently based on our current tile type we are interacting with.
Tutorials

Using Godot 4's TileMaps and Custom Data Layers: Godot Fundamentals

In Series: Godot Fundamentals

Author Avatar
2023-07-12
14 min read

Table of Contents

Introduction 

In this post we’re going to continue our series on Godot Fundamentals. We’ll be covering the TileMap and TileSet features within Godot and how you can setup Terrains, Layers, Auto-Tiling, Patterns, and Custom Data for your Levels. By the end we should be able to design our Level using the our TileSet, and see our Tank behave differently based on our current tile type we are interacting with.

Video 

You can watch the video covering this topic, or go at your own pace following this post.

Terrain and Audio Assets 

Our project contains the terrain.png asset file that we'll be using for our TileSet, as well as the audio files for our drive sound which will tie in to our custom data layer for detecting which type of terrain our tank is driving on.

Download these assets from our project on Github , or create your own and ensure to import them into the project. Our terrain.png will go into our assets/sprites folder and our drive_default.wav and drive_water.wav will be going into our assets/audio/sfx folder.

For our driving audio files, we'll want to update properties in our Import tab and set their Loop Mode to enabled so we can loop the audio when it plays. We'll be controlling the start/stop of our audio via our tank's script.

Setup our TileSet in our TileMap 

Inside our scenes/world/world.tscn Scene, we want to select our existing TileMap node and in the Inspector we'll be looking at the Layers section and adding a new Element. We'll call this layer terrain and drag it's order to be the first index in our list. We'll also want to set the Z Index on the terrain and walls layer to be set to -10, this will draw the tiles below the rest of our game objects and keep things from disappearing on us.

Next in our TileSet Panel at the bottom, we'll be adding a new Tile inside our Tiles tab, select the + icon to add a new Atlas Tile, drag over our terrain.png and allow Godot to automatically set the Base Tiles for us. We can name this new TileSet: terrain.

TileSet Terrain Sets 

Once we have our TileSet created, we will want to create our Terrain Sets within the Inspector, this is under the TileSet resource of our TileMap. Expand the Terrain Sets section and expand Terrains and add a new Element. We'll name it water, and we can give it a color of some light blue, something that will contract our actual water when overlaying, as this will be used to visualize which tiles belong to which Terrain Set later. Repeat this step for the dirt and grass Terrains and set their colors appropriately.

Now we can setup our Terrain properties per tile by opening the TileSet, selecting the terrain Tile, and then going to our Paint tool, selecting the Terrains Paint Properties, and the Terrain Set we want to paint to. This will be the only one in the drop down. Once the Set is selected, you can choose the Terrain that you'd like to paint the value onto a given tile.

You can then select the tiles and respective BitMasks in the tile pane. This is done using the 3x3 patter, where an enabled bit indicated the neighbor in that direction also belongs to the same terrain, and should auto-tile to that bit. Repeat for each type of terrain, and ensure each tile has the proper bitmasks to allow auto-tiling to occur.

Build your Level using the TileMap and Auto-Tiling 

With the TileSet setup and the tiles defined, you can select your TileMap tab and select your paint/pencil tool of your choice and start creating a quick level, you can use the rect tool and paint large squares into your scene. This should be showing the results of the auto-tile fairly quickly, and if you aren't able to correct mistakes in the corners or on transitions with simple edits, you may need to check your bitmasks are setup properly.

Alternative Tiles and Probability 

You may have noticed that your terrain has a lot of noise if you are also using alternative tiles. This is because you also need to paint in the probability property to each tile to ensure that it's weighted to best suit how often you want those tiles to show up.

Back in the TileSet panel, select your Paint tool and select the Probability Paint Property. You can set the value, for our example we'll use a number between .01 and .05 to vary the grass and dirt tiles. Paint these onto the tiles you'd like to lower the likelyhood of showing up, and then you can edit your map to replace those tiles. I like to give the bucket fill tool a shot here, and I can quickly test large batches at the current probabilities, then dialing it in from there.

TileSet Patterns 

Another great feature is the ability to take a selection of tiles, laid out to your liking, and select them and drag them into your Patterns tab, from here you can quickly reuse tiles in given shapes or patterns, or with a given composition of terrains and layouts. Once you have them in your Pattern Palette, you can select one and use the paint tools to draw them onto your map freely.

Add Additional Walls 

Now that we a more cohesive map laid out, let's also increase our walls structure to give a it more collisions in our game, we can select our walls layer and select our wall tile and start painting those in, including with patterns as well. Once we feel good about our number of walls we can move on.

Custom Data Layers 

Next we're going to add a custom data layer to our TileMap that will allow our tank to modify it's speed based on the type of tile that it's on. We can do this by opening the TileSet in the Inspector, and expanding the Custom Data Layers section. From here we can add an Element and we'll call it speed_modifier and we'll give it a type of float.

Once we've created our layer in the TileSet, we'll want to paint this property onto each tile with a given value. So open the TileSet panel at the bottom, select our terrain Tiles and the Paint tool. Select the Custom Data Layer called speed_modifier and in the field, we'll give it a value of 1.0 for grass, .8 for dirt, and .25 for our water. We can tweak these values as we see fit, but the goal is to see a transition of speed between the types of terrains, you could also use an inbetween value for the tiles that contain transition from a faster to slower speed, such as dirt to water.

With the Custom Data Layer created on our TileSet, we need to enable the use of it within our Tank script. Open the scenes/entities/tank/tank.tscn and we'll be adding a new variable called speed_modifier of type float, with a default value of 1.0

class_name Tank

...
var speed_modifier := 1.0

We'll want to be able to update this new variable by reading the TileMap from our World Scene and inspecting it's value based on our tank's current position.

To do this we'll need to create a new script attached to our World Scene called scenes/world/world.gd, and this will look like the following:

extends Node2D
class_name World

@onready var tile_map: TileMap = $TileMap

static var _instance: World = null

func _ready():
    _instance = self if _instance == null else _instance


static func get_tile_data_at(position: Vector2) -> TileData:
    var local_position: Vector2 = _instance.tile_map.local_to_map(position)
    return _instance.tile_map.get_cell_tile_data(0, local_position)


static func get_custom_data_at(position: Vector2, custom_data_name: String) -> Variant:
    var data = get_tile_data_at(position)
    return data.get_custom_data(custom_data_name)

Let's break this down. We've setup a class for our World, it will have on @onready variable set to a reference of our World's TileMap within it's node tree. We'll be using a static reference variable on our World class to the currently ready instance of the World. This allows us to statically address a specific instance that is defined without referencing in our other code.

Our get_tile_data_at function is responsible for taking in a position and converting it to a local map position and getting us the TileData from that position.

Our get_custom_data_at function also takes in a position, and from there it grabs the tile data and checks it's custom data for the given string.

Once we've defined this script, we can then modify our Tank's _physics_process function to include a call to the World to get the speed_modifier value and update our speed and velocity accordingly.

class_name Tank
...
func _phsycis_process(delta):
    ...
    speed_modifier = World.get_custom_data_at(position, "speed_modifier")
    var move_speed = SPEED * speed_modifier
    velocity = lerp(velocity, (direction.normalized() * input_direction.y) * move_speed, SPEED * delta)
    

If we test this, our velocity should be updated according to the type of tile we are driving on.

Particles based on Custom Data Layer 

Next we can create a visual indicator of the Custom Data Layer for which type of tile we are on. We can do this by introducing another Custom Data Layer for our TileSet. We'll call this layer tile_type, this will be of type int and we can leverage the values based on an Enumerator that we'll create in our World script. Back in our World's script, let's add a new enum that looks like the following:

class_name World
...
enum TILE_TYPES { NONE, WATER, DIRT, GRASS }

We can use the index of the values in our enum to define our int within each tile's tile_type property we want to paint in. Back in our Paint Property tool, set each Water, Dirt, and Grass tile to the proper tile_type int.

Next we'll create a GradientTexture1D for each terrain type that will be the color ramp of our particle emitters. We'll use exported variables within our World to easily allow us to define them. We'll also map those properties to a color ramp base don the TILE_TYPE. We'll also create a static function get_gradient_at that will be able to fetch the proper gradient from this map based on the position's current tile_type.

class_name World
...
@export var water_color: GradientTexture1D
@export var dirt_color: GradientTexture1D
@export var grass_color: GradientTexture1D

@onready var tile_particle_ramps = {
    TILE_TYPES.NONE: null,
    TILE_TYPES.WATER: water_color,
    TILE_TYPES.DIRT: dirt_color,
    TILE_TYPES.GRASS: grass_color,
}
...
static func get_gradient_at(position: Vector2) -> GradientTexture1D:
    var tile_type = get_custom-data_at(position, "tile_type")
    return _instance.tile_particle_ramps.get(tile_type, null)

Once we've exported the variables, we can create our Gradients for each property within our World's Inspector and set the ramp based on the colors we'd like to see. For our water example we'll start with a blueish white color, then gradually to a blue like our water, then a darker blue than our water, with a fair bit of transparency. We can repeat this process for the dirt and grass gradients. Note that using the color picker to select your tile colors is a great shortcut when setting this up.

Next we'll create our GPUParticles2D nodes called LeftTrackParticles and RightTrackParticles for each of these we'll disable the Emitting flag, set the amount to 128 and create a ParticleProcessMaterial with the following settings:

  • Emission Shape of Box with an x of -16, to span our tank's track width
  • Align particles to the Y in our flags
  • Direction y value of -16 (positive 16 for the right track) and a spread of 25
  • Gravity all to 0
  • Initial Velocity max of 4
  • Damping max of 3
  • Scale min of .25
  • Color will be one of our Gradients we created earlier, you can copy one for testing, but we'll change this in code.

Align the Left and Right particle emitters to the tank's tracks, so the emitting is happening from the tracks. We also want the Ordering Z Index of each emitter to be -1 relative to the tank to ensure the particles emit below the tank sprite, rather than above.

Now that we have our Tank's new nodes defined, let's update our tank.gd script to be able to update the emitters when we move, checking for which gradient it should be using based on our current position.

class_name Tank
...
@onready var left_track_particles: GPUParticles2D = $LeftTrackParticles
@onready var right_track_particles: GPUParticles2D = $RightTrackParticles
...
var particle_gradient: GradientTexture1D = null
...
func _physics_process(delta):
    ...
    if input_direciton.y != 0:
        ...
        particle_gradient = World.get_gradient_at(position)
        ...
    if particle_gradient and velocity:
        left_track_particles.process_material.color_ramp = particle_gradient
        right_track_particles.process_material.color_ramp = particle_gradient
        left_track_particles.emitting = true
        right_track_particles.emitting = true
    else:
        left_track_particles.emitting = false
        right_track_particles.emitting = false
    ...

Now our tank should be emitting particles when we move our tank, and the colors should match based on the type of tile we are on.

Change Tank's Audio based on Tile Type 

In our TileSet, we'll add a new layer that we want to check if the tile is_water and if we detect that we'll be change our Tank's engine audio to use the water driving sound. Once we have our is_water boolean Custom Data Layer, we can paint the water tile to be true. From our Tank's script we'll add a new variable called in_water that will check if the tile at our position is water and if so we'll swap the audio sound and play it instead. We'll also export out the default and water sounds as properties so we can define them on our Tank objects. We'll want to create a new AudioStreamPlayer node for our Tank that will control the Audio of our engine, and we can lower it's db to -10 and it's Bus is on our SFX bus we defined previously. We can set the stream to our default drive audio file.

class_name Tank
...
@export var drive_water_audio: AudioStream
@export var default_drive_audio: AudioStream
...
@onready var audio_player: AudioStreamPlayer = $AudioStreamPlayer
...
var in_water := false:
    set(value):
        if in_water != value:
            if value:
                audio_player.stream = drive_water_audio
            else:
                audio_player.stream = default_drive_audio
        in_water = value
...
func _physics_process(delta):
    ...
    if input_direction.y != 0:
        ...
        in_water = World.get_custom_data_at(position, "is_water")
        ...
        if !audio_player.playing:
            audio_player.play()
    else:
        if audio_player.playing:
            audio_player.stop()
        ...

Here we export 2 variables for AudioStreams that will be for our default and water audio files.

We also setup a variable called in_water that is a boolean that we define a setter function on so that when this value is updated, we'll check it's value against our current value and if it's updated we'll update the audio_player's stream property to match the proper audio.

We also check our audio_player's state before playing and stopping it when the tank moves.

Recap 

There we have a new map to drive around in, with the ability to detect which type of tile we're interacting with and modify the particles we show as well as how fast our tank moves.

Learn more about Custom Data Layers from Godot's official documentation  and see what else you can accomplish with TileMaps.

If you wanna discuss in more detail, join our Discord community . As always, stay tuned for more content.

Happy Coding!

Want to support our work?Support us via Ko-fi