Add obstacles

Tutorial

·

Beginner

·

+0XP

·

15 mins

·

Unity Technologies

Add obstacles

In order to make your game more challenging, it could use some obstacles to make navigating the game board less straightforward.

By the end of this tutorial, you’ll have done the following:

  • Created wall prefabs.
  • Coded the functionality to have a random number of Wall GameObjects spawn on the game board when the game starts.
  • Coded the functionality that denies the player character access to the tile the Wall GameObject occupies.
  • Added the functionality that allows the player character to damage the Wall GameObject, and once destroyed, move to the tile it used to occupy.

1. Overview

To make navigating the game board more interesting, this tutorial will guide you through adding walls in random places on the board. Then, you’ll code the functionality to have the player character destroy these walls and remove them from the game board.

2. Add obstacles

Let’s build on that CellObject concept by creating an obstacle for the player to overcome. A wall that blocks the player character is a good candidate for this, because you’ve already done something similar with the borders of the board. Let’s also add the functionality that every time the player bumps into it, the wall gets damaged, and after a certain amount of damage, the wall gets destroyed and the cell is now passable.

There are a couple of sprites that you can use for walls:


These sprites are full tile sprites, which means they cover the entire cell, like the ground or wall sprites you used before. You can use them as normal sprites and render them on top of the tilemap, and once they get destroyed the base tilemap will be shown. But instead of that, let’s modify which tile the tilemap uses in that particular cell.

In a game this small, the difference between these two techniques is minimal, but on large scale maps this could change a lot: individual sprites are rendered one after another, but tilemaps are rendered in a batch, so tilemaps are a lot more efficient.

You can’t add components (like the CellObject) to individual cells. However, this won’t be an issue because this new type of cell object, let’s call them WallObjects, will just be an empty GameObject with no sprite. It will still be placed in the cell, but it will only be there to hold the component and change the tilemap under it through a script.

To create and add WallObjects to your game, follow these instructions:

1. In the Hierarchy window, create an empty GameObject and name it “Wall”.

Remember, you aren’t using sprites; the CellObject will modify the tilemap.

2. In the Project window, right click the Scripts folder and select Create > Scripting > MonoBehaviour Script, and name it “WallObject”.

public class WallObject : CellObject
{
}

WallObject, just like FoodObject, inherits from CellObject.

3. Add the WallObject script as a component to the Wall GameObject, click and drag the Wall GameObject into your Prefabs folder, then delete the Wall GameObject from the scene.

4. Add a public variable of type WallObject called “WallPrefab” to your BoardManager and create a new GenerateWall function that will be very similar to your GenerateFood function.

The GenerateWall function will spawn a certain number of walls in the level.

void GenerateWall()
{
   int wallCount = Random.Range(6, 10);
   for (int i = 0; i < wallCount; ++i)
   {
       int randomIndex = Random.Range(0, m_EmptyCellsList.Count);
       Vector2Int coord = m_EmptyCellsList[randomIndex];

       m_EmptyCellsList.RemoveAt(randomIndex);
       CellData data = m_BoardData[coord.x, coord.y];
       WallObject newWall = Instantiate(WallPrefab);

       newWall.transform.position = CellToWorld(coord);

       data.ContainedObject = newWall;
   }
}

5. Call the above GenerateWall method inside the Init method, before the call to the GenerateFood function.

public void Init()
    {
        ... (all code above)
        GenerateWal() ; // new line
        GenerateFood();
    }

This way all the cells that will generate walls will be removed from the empty cell list and food won’t be able to generate there.

In the Editor, assign your WallPrefab to the WallPrefab slot on your BoardManager and if you enter Play mode now, because the WallObjects class is empty, they won't change the board yet and you won’t be able to see them, but selecting them in the Hierarchy window will show they are at the right place in the level. Finding ways like this to test features even when they aren’t finished allows you to reduce the scope of code you’ll need to debug if there are any problems, so always try to think of a way to test something you’re doing intermittently. Even if the walls aren’t visible, by using the Scene view and Hierarchy window, you can check that the generating code works before going to the next step.

The Wall GameObject will need to do two things that CellObject can’t do for now:

  • Change the tile sprite it's placed on to a wall sprite.
  • Stop the player character from entering a cell.

Let’s first tackle the tile changing problem. As discussed before, when presented with a problem, it’s a good idea to try and make it more generic so that you can craft a solution that might cover more than one use case.

The goal is for the code to perform an action when the CellObject is placed over a cell. This seems like something that you might want to do with other CellObjects, not only walls. So it’ll be beneficial to add a new virtual function to CellObject that’s called after the object is placed. This function will also need to know the cell it’s placed on.

We can combine all these requirements into an Init virtual function that takes a Vector2Int storing the coordinate of the cell it’s placed on.

public class CellObject : MonoBehaviour
{
   protected Vector2Int m_Cell;

   public virtual void Init(Vector2Int cell)
   {
       m_Cell = cell;
   }
  
   public virtual void PlayerEntered()
   {
      
   }
}

Note: m_Cell is protected, not private. Private would make it inaccessible to the subclasses of CellObject like FoodObject or WallObject. Instead, protected will let other subclasses like WallObject access it, while still hiding it from other unrelated classes.

6. Update the GenerateWall function in the BoardManager script to the following, calling Init just after instantiating the new Wall:

void GenerateWall()
{
   int wallCount = Random.Range(6, 10);
   for (int i = 0; i < wallCount; ++i)
   {
       int randomIndex = Random.Range(0, m_EmptyCellsList.Count);
       Vector2Int coord = m_EmptyCellsList[randomIndex];

       m_EmptyCellsList.RemoveAt(randomIndex);
       CellData data = m_BoardData[coord.x, coord.y];
       WallObject newWall = Instantiate(WallPrefab);
      
       //init the wall
       newWall.Init(coord);
      
       newWall.transform.position = CellToWorld(coord);

       data.ContainedObject = newWall;
   }
}

To finalize this group of changes, you need to override the Init function in the WallObject class for it to change the Tilemap tile. However, as you try doing this, you might realize that there is no access to the Tilemap from outside the BoardManager class. Like the method you added to change cell index to world position, it makes sense to add this method on the BoardManager.

7. Add the following new method to BoardManager:

public void SetCellTile(Vector2Int cellIndex, Tile tile)
{
   m_Tilemap.SetTile(new Vector3Int(cellIndex.x, cellIndex.y, 0), tile);
}

Because this new function is public, you can use it on WallObject to finally change the Tile in the Init function.

8. Add the following code to your script:

using UnityEngine;
using UnityEngine.Tilemaps;

public class WallObject : CellObject
{
   public Tile ObstacleTile;
  
   public override void Init(Vector2Int cell)
   {
       base.Init(cell);
       GameManager.Instance.BoardManager.SetCellTile(cell, ObstacleTile);
   }
}

Note: Make sure to call base.Init() first. This will call the Init function from the class it inherits from (CellObject), and assign the m_Cell variable before setting the tile. If you don’t call base.Init first, the m_Cell variable will be unassigned! As a general rule, always call the base version of an overridden function unless you know that base version does nothing by default or you want to completely override what it does.

9. Save your scripts. Select the Wall prefab and, in the Inspector window, assign the tile you want to use to the Obstacle Tile slot.

If the sprite you want to use isn’t a tile yet, create it through the TilePalette like you did in this earlier lesson)

If you enter Play mode now, you should finally see the wall tiles being added to the game board randomly.

However, the player character can still move on top of the walls. This is because right now the walls just change the tilemap tile when initialized. To stop the player character from moving on top of the walls, you can change the Passable property of the CellData to false in the Init code of the WallObject.

This works well on its own, but in the game, you also want the player character to be able to destroy the wall by bumping into it a certain number of times. But your player character movement code doesn't even check if a cell has anything in it if it isn’t passable.

As always, when presented with a problem, it’s helpful to describe it in abstract to figure out what you need to do to solve it. In this case:

  • Detect if the player wants to move to a certain cell.
  • Stop the player from moving there if the cell has a special condition (in this case, has a wall).

To add this functionality, the player character code needs to ask the CellObject code if the player character can enter the cell, and if the CellObject code will allow it or not.

A new virtual PlayerWantsToEnter method in CellObject can serve this purpose. The player character movement code calls this method on the object in a cell when the player character tries to move there, and this method either returns true if the player character can enter the cell (like for FoodObject) or cannot (like for WallObject):

public virtual bool PlayerWantsToEnter()
{
   return true;
}

Then you can use that new method by modifying the code in the if(haseMoved) condition from the Update function of the PlayerController:

if(cellData != null && cellData.Passable)
{
   GameManager.Instance.TurnManager.Tick();

   if (cellData.ContainedObject == null)
   {
       MoveTo(newCellTarget);
   }
   else if(cellData.ContainedObject.PlayerWantsToEnter())
   {
       MoveTo(newCellTarget);
       //Call PlayerEntered AFTER moving the player! Otherwise not in cell yet
       cellData.ContainedObject.PlayerEntered();
   }
}

Let’s take a closer look at the lines of code above:

  • If a cell is passable the game ticks once (bumping into an object counts as a tick, but not being able to enter a cell like an outer wall or non destructible wall doesn’t).
  • Then, a conditional block will run a couple of checks:
    • If the cell the player is trying to move to doesn’t have a cell object, the player character can move to that cell.
    • If the cell has a cell object, it will call PlayerWantsToEnter on that cell object and if that returns true, then the player character can enter.

Right now, the base version of the PlayerWantsToEnter function returns true, so the player character can always move to the cells, but let’s override the function inside WallObject to be able to also return false when the CellObject is a wall:er character can enter.

public class WallObject : CellObject
{
   public Tile ObstacleTile;
  
   public override void Init(Vector2Int cell)
   {
       base.Init(cell);
       GameManager.Instance.BoardManager.SetCellTile(cell, ObstacleTile);
   }

   public override bool PlayerWantsToEnter()
   {
       return false;
   }
}

Now if you enter Play mode, your newly placed wall should stop the player character from moving to the cell!

Tip: Remember to check in your changes to UVCS!

3. Refactoring

Before you continue, take some time to review what you’ve done so far and consider if there is an area where you can clean up the code or simplify it to better future proof it.

If you look for code duplication, you might notice that in BoardManager, there’s similar code used to place the food and the wall: both add a CellObject to a cell. But right now, your food placement doesn’t call Init on the new FoodObject. This isn’t a problem here as the FoodObject doesn’t override Init and doesn’t use the m_Cell property that the Init function initialized, but this could be a source of an error if you forget to initialize other CellObjects you write.

Therefore, now’s a good opportunity to consolidate this part of the code and create an AddObject function that will add a given CellObject into a given cell, and take care of placing it in the right place and calling Init on it:

void AddObject(CellObject obj, Vector2Int coord)
{
   CellData data = m_BoardData[coord.x, coord.y];
   obj.transform.position = CellToWorld(coord);
   data.ContainedObject = obj;
   obj.Init(coord);
}

Now let’s use this new function to add a FoodObject and WallObject to the cell instead of manually doing it; the new GenerateWall and GenerateFood functions now look like the following:

void GenerateWall()
{
   int wallCount = Random.Range(6, 10);
   for (int i = 0; i < wallCount; ++i)
   {
       int randomIndex = Random.Range(0, m_EmptyCellsList.Count);
       Vector2Int coord = m_EmptyCellsList[randomIndex];

       m_EmptyCellsList.RemoveAt(randomIndex);
       WallObject newWall = Instantiate(WallPrefab);
       AddObject(newWall, coord);
   }
}

void GenerateFood()
{
   int foodCount = 5;
   for (int i = 0; i < foodCount; ++i)
   {
       int randomIndex = Random.Range(0, m_EmptyCellsList.Count);
       Vector2Int coord = m_EmptyCellsList[randomIndex];

       m_EmptyCellsList.RemoveAt(randomIndex);
       FoodObject newFood = Instantiate(FoodPrefab);
       AddObject(newFood, coord);
   }
}

This way, if you use the AddObject function to add new kinds of objects to your board later, everything will be set properly.

Don’t forget to try the game in Play mode after all those changes to make sure you haven’t broken anything before you continue!

4. Damaging walls

Currently when the player bumps into a wall cell, they won't be able to move, but the food counter will decrease as bumping is taken as a turn. So, let’s add the functionality of damaging walls. You should be able to do this by yourself with everything you have learned so far. But first, let's write down the what this functionality needs to do:

  • Create a new counter variable in WallObject that stores the hitpoint count of that wall.
  • Save the original tile in that cell during Init before it’s replaced with the wall tile (so you can set it back after the wall is destroyed). This will require you to add a new function in BoardManager just like we did for SetCellTile!

Note: The Tilemap class contains a function called GetTile (similar to SetTile) that allows you to retrieve the tile of a given cell. But if you look at the documentation, you’ll see that this function returns a BaseTile, not a tile. Don’t worry, you can easily solve this by using the generic version of the function GetTile<Tile>() to retrieve the actual tile.

  • When the PlayerWantToEnter function is called in WallObject, reduce the hit point counter by 1, and when the hit points reach 0, the WallObject is destroyed, and the wall tile is removed and is set back to the original tile.

WallObject

public class WallObject : CellObject
{
   public Tile ObstacleTile;
   public int MaxHealth = 3;

   private int m_HealthPoint;
   private Tile m_OriginalTile;
  
   public override void Init(Vector2Int cell)
   {
       base.Init(cell);

       m_HealthPoint = MaxHealth;
      
       m_OriginalTile = GameManager.Instance.BoardManager.GetCellTile(cell);
       GameManager.Instance.BoardManager.SetCellTile(cell, ObstacleTile);
   }

   public override bool PlayerWantsToEnter()
   {
       m_HealthPoint -= 1;

       if (m_HealthPoint > 0)
       {
           return false;
       }

       GameManager.Instance.BoardManager.SetCellTile(m_Cell, m_OriginalTile);
       Destroy(gameObject);
       return true;
   }
}

BoardManager

public Tile GetCellTile(Vector2Int cellIndex)
    {
        return m_Tilemap.GetTile<Tile>(new Vector3Int(cellIndex.x,     cellIndex.y, 0));
    }

Above are our solutions; yours might be a bit different! Note that we declare a public property MaxHealth so we can change how much health a WallObject has in the Inspector window. That way we can potentially have walls that require more or less damage to be destroyed.

Enter Play mode and see how the player can now destroy the walls after a certain number of hits. Congratulations, you’ve added a lot of complexity to your gameplay! Don’t forget to save your changes to UVCS!

5. Challenges

Below are a couple of small challenges for you to add to the game!

Easy: Multiple walls

There are different tiles sprites that you can use for walls. Create a prefab of each one and then modify your BoardManager to pick one randomly when generating walls.

Medium: Change wall tiles based on how damaged they are

There is a second set of sprites for each of the wall sprites we showed in the previous challenge, but these sprites look half destroyed.

Improve your code so the wall tiles are changed to these damaged ones when they have only 1 HP left to let your player know the wall is nearly destroyed!

6. Next steps

Your game now has obstacles to avoid that make the game much more challenging and fun! In the next tutorial, you’ll bring everything you’ve done previously together by adding win and lose conditions!

Complete this tutorial