19 February 2025

Assigning data to tiles in Unity

Tilemaps are very often used to create 2D games. Their simplicity makes them ideal for creating a retro-style setting. 

However, at first glance, Unity's entire implementation of Tilemaps seems to be limited to its aesthetic aspect. That's why it's quite common to find people on the forums asking how to associate data with the different Tiles used in a Tilemap.

Why might we need to associate data with a tile? For a variety of reasons. For example, let's say we're using a Tilemap to map a top-down game. In that case, we'll probably want to add a "drag" value to the different tiles, so that our character moves slower on tiles that represent a swamp, faster on tiles that represent a path, and can't cross tiles that show impassable stones.

For our examples, let's assume a scenario like the one in the capture:

Scenario of our examples
Scenario of our examples


It represents an enclosed area that includes three obstacles inside, one on the left (with a single tile), one in the center (with four tiles) and one on the right (with three). The origin of the stage's coordinates is at its center; I have indicated it with the crosshair of an empty GameObject.

The problem we want to solve in our example is how to make a script analyze the scenario, identify the black tiles and take note of their positions.

As with many other cases, there is no single solution to this problem. We have an option that is quick to implement and offers more possibilities, but can put too much overhead on the game. On the other hand, we have another option that is more difficult to implement and is more limited, but will put less overhead on the game. Let's analyze both.

Associate a GameObject to a tile

Generally, when we want to identify at once the GameObjects that belong to the same category, the easiest way would be to mark them with a tag and search for them in the scenario with the static method  GameObject.FindGameObjectsWithTag() . The problem is that tiles are ScriptableObjects, so they cannot be marked with tags.

ScriptableObjects for tiles are created when we drag sprites onto the Tile Palette tab. At that point, the editor lets us choose the name and location of the asset with the ScriptableObject we want to create, associated with the tile. From that point on, if we click on the asset of that ScriptableObject we can edit its parameters through the inspector. For example, for the tile I used for the perimeter walls, the parameters are:

Setting up a Tile
Setting up a Tile


The fields that can be configured are:

  • Sprite: This is the sprite with the visual appearance of the tile. Once the sprite is set, we can press the "Sprite Editor" button below to configure both the pivot point and the collider associated with the sprite.
  • Color: Allows you to color the sprite with the color you set here. The neutral color is white; if you use it, Unity will understand that you do not want to force the sprite's color.
  • Collider Type: Defines whether we want to associate a Collider to the tile. If we choose "None" it will mean that we do not want the Tile to have an associated Collider; if we set "Sprite", the collider will be the one we have defined through the Sprite Editor; finally, if the chosen value is "Grid", the collider will have the shape of the Tilemap cells.
  • GameObject to Instantiate: This is the parameter we are interested in. We will explain this in a moment.
  • Flags: These are used to modify how a tile behaves when placed on a Tilemap. For our purposes, you can simply leave it at its default value.

As I was saying, the parameter that interests us for our purpose is "GameObject to instantiate" if we drag a prefab to this field, the Tilemap will be in charge of creating an instance of that prefab in each location where that Tile appears.

For example, to be able to easily locate the black tiles, those of the obstacles, I have associated a prefab to that parameter of their Tile that I have called ObstacleTileData.

Setting up the Obstacle Tile
Setting up the Obstacle Tile

Since all I want is to be able to associate a tag with the tiles, in order to locate them with  FindGameObjectsWithTag() , it was enough for me to make ObstacleTileData a simple transform with the tag I was interested in. In the screenshot you can see that I used the InnerObstacle tag .

ObstacleTileData with label InnerObstacle
ObstacleTileData with label InnerObstacle

Once this is done, and once the tiles we want to locate are deployed on the stage, we only need the following code to make an inventory of the tiles with the InnerObstacle tag .

Code to locate the tiles that we have marked with the InnerObstacle tag

We just need to place the above script on any GameObject located next to the stage's Tilemap. For example, I have it hanging from the same transform as the Grid component of the Stage's Tilemaps.

When the level starts, the Tilemap will create an instance of the ObstacleTileData prefab at each position on the stage where a black obstacle tile appears. Since the ObstacleTileData prefab has no visual component, its instances will be invisible to the player, but not to our scripts. Since these instances are marked with the "InnerObstacle" tag, our script can locate them by calling  FindGameObjectsWithTag() , on line 16 of the code. 

To demonstrate that the code correctly locates the obstacle tile locations, I've set a breakpoint on line 17, so that we can analyze the contents of the "obstacles" variable after calling  FindGameObjectsWithTag() . When running the game in debug mode, the contents of that variable are as follows:

Obstacle tile positions
Obstacle tile positions

If we compare the positions of the GameObjects with those of the tiles, we can see that obstacles[7] is the obstacle on the left, with a single tile. The GameObjects obstacle[2], [3], [5] and [6] correspond to the four tiles of the central obstacle. The three remaining GameObjects ([0], [1] and [4]) are the tiles of the obstacle on the right, the elbow-shaped one.

In this way, we have achieved a quick and easy inventory of all the tiles of a certain type.

However, pulling labels isn't the only way to locate instances of the GameObjects associated with each Tile. Tilemap objects offer the GetInstantiatedObject() method , which is passed a position within the Tilemap and in return returns the instantiated GameObject for that tile's tile. Using this method is less direct than locating objects by label, since it forces you to examine the Tilemap positions one by one, but there will be situations where you have no other choice.

Finally, before we leave this section of the article, you should be aware that there may be situations where instantiating a GameObject per tile can weigh down the performance of the game. In the example case, we are talking about a few tiles, but in much larger scenarios we may be talking about hundreds of tiles, so instantiating hundreds of GameObjects may be something to think twice about.

Extending the Tile class

By default, I would use the above strategy; but there may be situations where you don't want to instantiate a large number of GameObjects. In that case, you may want to use the approach I'm going to explain now.

Tiles are a class that inherits from ScriptableObject. We can extend the Tile class to add any parameters we want. For example, we could create a specialized Tile with a boolean to define whether the tile is an obstacle or not.

Tile with a specialized parameter
Tile with a specialized parameter

This tile can be instantiated like any ScriptableObject to create an asset. When we do this, we will see that the specialized parameter will appear and we can configure it through the inspector.

Setting the tile with the specialized parameter
Setting the tile with the specialized parameter

The key is that the assets we create this way can be dragged to the Tile Palette so they can be drawn on the stage.

Once that is done, we could use the Tilemap.GetTile() method to retrieve the tiles for each position, cast them to our custom tile type (in our case CourtyardTile) and then analyze the value of the custom parameter.

The drawback of this method is that we cannot use labels or layers to search for data associated with tiles, which forces us to go through the tilemap cell by cell to find them, but it has the advantage of freeing our game from the burden of creating a GameObject per tile.

Conclusion

Whether by creating a GameObject per tile or by extending the Tile class, you now have the resources necessary to associate data with each of the tiles. This will allow you to provide the tiles with essential semantics for a multitude of algorithms, such as pathfinding algorithms.

08 February 2025

2D Navigation in Godot

NPCs or Non-Playable-Characters are all the characters in the game that are not controlled by the player, but that interact with him. They can range from the player's allies to enemies that try to kill him. One of the great challenges for game developers is to equip their NPCs with a series of behaviors that convey the appearance of life and intelligence.

One of the clearest signs of life is movement. If something moves on its own initiative, one of the reasons our mind instinctively considers is that it may be alive. On the other hand, one of the signs of a minimum of intelligence is that this movement occurs while avoiding obstacles in its path. If a creature moves when we approach it, but immediately runs into the wall in front of it, we may think that this creature is alive, but not that it is very intelligent.

That's why most game engines, as long as they have an artificial intelligence package, first include some kind of pathfinding tool to allow NPCs to orient themselves around the environment.

Godot is no exception and therefore incorporates pathfinding functionality using meshes, both in 2D and 3D. For simplicity, we will focus on the first area.

Creating a map

To orient ourselves, the best thing for humans is a map. The same goes for an engine. That map is based on our scenario, but to model it we need a node called NavigationRegion2D. We must add it to the scene of any level we want to map. For example, supposing that our level is based on several TileMapLayers (one with the floor tiles, another with the perimeter walls and another with the tiles of the obstacles inside the perimeter), the node structure with the NavigationRegion2D node could be the following:

Node hierarchy with a NavigationRegion2D
Node hierarchy with a NavigationRegion2D

Note that the NavigationRegion2D node is the parent of the TileMapLayer since by default it only builds the map with the conclusions it draws from analyzing its child nodes.

When configuring the NavigationRegion2D through the inspector, we will see that it requires the creation of a NavigationPolygon resource, in this resource the map information of our level will be saved.

Setting up a NavigationRegion2D
Setting up a NavigationRegion2D


Once the resource has been created, when clicking on it, we will see that it has quite a few properties to configure.

Setting up a NavigationPoligon
Setting up a NavigationPoligon


In a simple case like our example, only two parameters need to be configured:
  • Radius : Here we will set the radius of our NPC, with a small additional margin. This way, the pathfinding algorithm will add this distance to the outline of the obstacles to prevent the NPC from rubbing against them.
  • Parsed Collision Mask : All objects of the child nodes that are in the collision layers that we mark here will be considered an obstacle.
Once this is done, we will only need to mark the limits of our map. To do this, note that when you click on the NavigationPolygon resource in the inspector, the following toolbar will appear in the scene tab:

Toolbar for defining the shape of a NavigationPolygon
Toolbar for defining the shape of a NavigationPolygon


Thanks to this toolbar we can define the boundaries of our map. Then, NavigationRegion2D will be in charge of identifying obstacles and making "holes" in our map to indicate the areas through which our NPC will not be able to pass.

The first button (green) on the toolbar is used to add new vertices to the shape of our map, the second button (blue) is used to edit a vertex already placed, while the third (red) is used to delete vertices.

In a simple scenario, such as a tilemap, it may be enough to draw the four vertices that limit the maximum extension of the tilemap. In the following screenshot you can see that I have placed a vertex in each corner of the area I want to map.

Vertices of our NavigationPolygon
Vertices of our NavigationPolygon


Once we have defined the boundaries of our map, we will have to start its generation. To do this, we will press the Bake NavigationPolygon button in the toolbar. 
The result will be that NavigationRegion2D will mark in blue the areas where you can wander, once the limits of the map have been analyzed and the obstacles within them have been detected.

We will have to remember to press the Bake NavigationPolygon button whenever we add new obstacles to the level or move existing ones, otherwise the map of the navigable areas will not update.

NavigationPolygon, after being baked.
NavigationPolygon, after being baked.


For an object to be identified as an obstacle, it has to be configured to belong to one of the collision layers that we have configured in the Parsed Collision Mask field of the NavigationPolygon. In the case of a TileMapLayer this is configured as follows:
  1. In those TileMapLayers that contain obstacles we will have to mark the Physics -> Collision Enabled parameter.
  2. In the Tile Set resource that we are using in the TileMapLayer, we will have to make sure to add a physical layer and place it in one of the layers contemplated in the Parsed Collision Mask of the NavigationPolygon.
  3. You must also add a navigation layer and set it to the same value as the NavigationRegion2D node's navigation layer.
For example, the TileMapLayer containing the interior obstacles in my example, has the following settings for the Tile Set:

Setting up the TileMapLayer
Setting up the TileMapLayer


Then, inside the TileSet, not all tiles have to be obstacles, only those that have a collider configured. Remember that the tiles' colliders are configured from the TileSet tab. I won't go into more detail about this because that has more to do with the TileMaps configuration than with the navigation itself.

Setting a tile's collider
Setting a tile's collider


Using the map

Once the map is created, our NPCs need to be able to read it. To do this, they need a MeshNavigationAgent2D node in their hierarchy.

The MeshNavigationAgent2D node within an NPC's hierarchy
The MeshNavigationAgent2D node within an NPC's hierarchy


In a simple case, you may not even need to change anything from its default settings. Just make sure its Navigation Layers field is set to the same value as the NavigationRegion2D.

From your script, once you have a reference to the MeshNavigationAgent2D node, you will simply have to set the position you want to go to in the TargetPosition property of the MeshNavigationAgent2D. For example, if we had an NPC who wanted to hide at a point on the map, we could include a property in which we would ask the MeshNavigationAgent2D node to find the route to reach that point, as you can see in the screenshot.


Setting a target position in a MeshNavigationAgent2D
Setting a target position in a MeshNavigationAgent2D

Once we have told the node where we want to go, it will calculate a route and give us the different stops on that route as we reach them.

In order for the node to tell us where the next stop on the path is, we will have to call the GetNextPathPosition() method. It is important to note that this method is responsible for updating quite a few things internal to the pathfinding algorithm, so a requirement is that we call it once in each call to _PhysicsProcess() of our NPC.

In the screenshot you have the _PhysicProcess() of the agent I'm using as an example. Most of the code in the screenshot refers to topics that are not the subject of this article, but I'm including it to provide some context. In fact, for what we're talking about, you only need to look at lines 221 to 225.

Obtaining the next stop on the route, within the _PhysicsProcess.
Obtaining the next stop on the route, within the _PhysicsProcess.


On line 225 you can see how we call GetNextPathPosition() to get the next point we need to go to in order to follow the path drawn to the target set in TargetPosition. How we get to that point is up to us. The MeshNavigationAgent2D simply guarantees two things: 
  • That there is no obstacle between the NPC and the next point on the route that he marks for you.
  • That if you follow the route points that it gives you, you will end up reaching the objective... if there is a route that leads to it.
I want to emphasize this because, unlike Unity's NavigationAgent, Godot's does not move the NPC in which it is embedded. It simply gives directions on where to go.
Apart from the general case, there are certain caveats in the capture code that need to be made clear.

For starters, the Godot documentation says not to keep calling GetNextPathPosition() once you've finished traversing the path, otherwise the NPC may "shake" by forcing further updates to the pathfinding algorithm after already reaching the goal. That's why, on line 224, I check that we haven't reached the end of the path yet, before calling GetNextPathPosition(). So don't forget to check that IsNavigationFinished() returns false, before checking GetNextPathPosition().

On the other hand, the pathfinding algorithm takes a while to converge, especially at the beginning. If we ask it too early it will throw an exception. Typically it takes one or two physics frames (i.e. one or two calls to _PhysicsProcess). That is why on line 222 it checks if the IsReady property is true before continuing. The problem is that MeshNavigationAgent2D does not have an IsReady property (although it should have one), it is just a property I created myself to make a non-intuitive query:

How to check that the pathfinding algorithm is ready to answer queries
How to check that the pathfinding algorithm is ready to answer queries


Basically, what the property does is ensure that the pathfinding algorithm has managed to generate at least one version of the path. 

Conclusion

And that's it, by setting the TargetPosition of the MeshNavigationAgent2D, and going to the successive points marked by the calls to GetNextPathPosition(), you will be able to reach any reachable point of the NavigationRegion2D that you have defined. 

With that you already have the basics, but if at any time you need to analyze the complete route that the algorithm has calculated, you can ask for it by calling the GetCurrentNavigationPath() method. This method will return an array with the positions of the different stops on the route.