Unity 2D Tile-Based ‘Sokoban’ Game

What You’ll Be Creating

In this tutorial we will be exploring an approach for creating a sokoban or crate-pusher game using tile-based logic and a two-dimensional array to hold level data. We are using Unity for development with C# as the scripting language. Please download the source files provided with this tutorial to follow along.

1. The Sokoban Game

There may be few among us who may not have played a Sokoban game variant. The original version may even be older than some of you. Please check out the wiki page for some details. Essentially, we have a character or user-controlled element which has to push crates or similar elements onto its destination tile.

The level consists of a square or rectangular grid of tiles where a tile can be a non-walkable one or a walkable one. We can walk on the walkable tiles and push the crates onto them. Special walkable tiles would be marked as destination tiles, which is where the crate should eventually rest in order to complete the level. The character is usually controlled using a keyboard. Once all crates have reached a destination tile, the level is complete.

Tile-based development essentially means that our game is composed of a number of tiles spread in a predetermined way. A level data element will represent how the tiles would need to be spread out to create our level. In our case, we’ll be using a square tile-based grid. You can read more on tile-based games here on Envato Tuts+.

2. Preparing the Unity Project

Let’s see how we have organised our Unity project for this tutorial.

The Art

For this tutorial project, we are not using any external art assets, but will use the sprite primitives created with the latest Unity version 2017.1. The image below shows how we can create different shaped sprites within Unity.

We will use the Square sprite to represent a single tile in our sokoban level grid. We will use the Triangle sprite to represent our character, and we will use the Circle sprite to represent a crate, or in this case a ball. The normal ground tiles are white, whereas the destination tiles have a different colour to stand out.

The Level Data

We will be representing our level data in the form of a two-dimensional array which provides the perfect correlation between the logic and visual elements. We use a simple text file to store the level data, which makes it easier for us to edit the level outside of Unity or change levels simply by changing the files loaded. The Resources folder has a level text file, which has our default level.

The level has seven columns and five rows. A value of 1 means that we have a ground tile at that position. A value of -1 means that it is a non-walkable tile, whereas a value of 0 means that it is a destination tile. The value 2 represents our hero, and 3 represents a pushable ball. Just by looking at the level data, we can visualise what our level would look like.

3. Creating a Sokoban Game Level

To keep things simple, and as it is not a very complicated logic, we have only a single Sokoban.cs script file for the project, and it’s attached to the scene camera. Please keep it open in your editor while you follow the rest of the tutorial.

Special Level Data

The level data represented by the 2D array is not only used to create the initial grid but is also used throughout the game to track level changes and game progress. This means that the current values are not sufficient to represent some of the level states during game play.

Each value represents the state of the corresponding tile in the level. We need additional values for representing a ball on the destination tile and the hero on the destination tile, which respectively are -3 and -2. These values could be any value that you assign in the game script, not necessarily the same values we have used here.

Parsing the Level Text File

The first step is to load our level data into a 2D array from the external text file. We use the ParseLevel method to load the string value and split it to populate our levelData 2D array.

For our level, we have set a tileSize value of 50, which is the length of the side of one square tile in our level grid. We loop through our 2D array and determine the value stored at each of the i and j indices of the array. If this value is not an invalidTile (-1) then we create a new GameObject named tile. We attach a SpriteRenderer component to tile and assign the corresponding Sprite or Color depending on the value at the array index.

While placing the hero or the ball, we need to first create a ground tile and then create these tiles. As the hero and ball need to be overlaying the ground tile, we give their SpriteRenderer a higher sortingOrder. All tiles are assigned a localScale of tileSize so they are 50x50 in our scene.

We keep track of the number of balls in our scene using the ballCount variable, and there should be the same or a higher number of destination tiles in our level to make level completion possible. The magic happens in a single line of code where we determine the position of each tile using the GetScreenPointFromLevelIndices(int row,int col) method.

The world position of a tile is determined by multiplying the level indices with the tileSize value. The middleOffset variable is used to align the level in the middle of the screen. Notice that the row value is multiplied by a negative value in order to support the inverted y axis in Unity.

4. Sokoban Logic

Now that we have displayed our level, let’s proceed to the game logic. We need to listen for user key press input and move the hero based on the input. The key press determines a required direction of motion, and the hero needs to be moved in that direction. There are various scenarios to consider once we have determined the required direction of motion. Let’s say that the tile next to hero in this direction is tileK.

Is there a tile in the scene at that position, or is it outside our grid?

Is tileK a walkable tile?

Is tileK occupied by a ball?

If the position of tileK is outside the grid, we do no need to do anything. If tileK is valid and is walkable, then we need to move hero to that position and update our levelData array. If tileK has a ball, then we need to consider the next neighbour in the same direction, say tileL.

Is tileL outside the grid?

Is tileL a walkable tile?

Is tileL occupied by a ball?

Only in the case where tileL is a walkable, non-occupied tile should we move the hero and the ball at tileK to tileK and tileL respectively. After successful movement, we need to update the levelData array.

Supporting Functions

The above logic means that we need to know which tile our hero is currently at. We also need to determine if a certain tile has a ball and should have access to that ball.

To facilitate this, we use a Dictionary called occupants which stores a GameObject as key and its array indices stored as Vector2 as value. In the CreateLevel method, we populate occupants when we create hero or ball. Once we have the dictionary populated, we can use the GetOccupantAtPosition to get back the GameObject at a given array index.

The IsOccupied method determines whether the levelData value at the indices provided represents a ball.

private bool IsOccupied(Vector2 objPos)
{//check if there is a ball at given array position
return (levelData[(int)objPos.x,(int)objPos.y]==ballTile || levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile);
}

We also need a way to check if a given position is inside our grid and if that tile is walkable. The IsValidPosition method checks the level indices passed in as parameters to determine whether it falls inside our level dimensions. It also checks whether we have an invalidTile as that index in the levelData.

Responding to User Input

In the Update method of our game script, we check for the user KeyUp events and compare against our input keys stored in the userInputKeys array. Once the required direction of motion is determined, we call the TryMoveHero method with the direction as a parameter.

The TryMoveHero method is where our core game logic explained at the start of this section is implemented. Please go through the following method carefully to see how the logic is implemented as explained above.

private void TryMoveHero(int direction)
{
Vector2 heroPos;
Vector2 oldHeroPos;
Vector2 nextPos;
occupants.TryGetValue(hero,out oldHeroPos);
heroPos=GetNextPositionAlong(oldHeroPos,direction);//find the next array position in given direction
if(IsValidPosition(heroPos)){//check if it is a valid position & falls inside the level array
if(!IsOccupied(heroPos)){//check if it is occupied by a ball
//move hero
RemoveOccupant(oldHeroPos);//reset old level data at old position
hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y);
occupants[hero]=heroPos;
if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){//moving onto a ground tile
levelData[(int)heroPos.x,(int)heroPos.y]=heroTile;
}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){//moving onto a destination tile
levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile;
}
}else{
//we have a ball next to hero, check if it is empty on the other side of the ball
nextPos=GetNextPositionAlong(heroPos,direction);
if(IsValidPosition(nextPos)){
if(!IsOccupied(nextPos)){//we found empty neighbor, so we need to move both ball & hero
GameObject ball=GetOccupantAtPosition(heroPos);//find the ball at this position
if(ball==null)Debug.Log("no ball");
RemoveOccupant(heroPos);//ball should be moved first before moving the hero
ball.transform.position=GetScreenPointFromLevelIndices((int)nextPos.x,(int)nextPos.y);
occupants[ball]=nextPos;
if(levelData[(int)nextPos.x,(int)nextPos.y]==groundTile){
levelData[(int)nextPos.x,(int)nextPos.y]=ballTile;
}else if(levelData[(int)nextPos.x,(int)nextPos.y]==destinationTile){
levelData[(int)nextPos.x,(int)nextPos.y]=ballOnDestinationTile;
}
RemoveOccupant(oldHeroPos);//now move hero
hero.transform.position=GetScreenPointFromLevelIndices((int)heroPos.x,(int)heroPos.y);
occupants[hero]=heroPos;
if(levelData[(int)heroPos.x,(int)heroPos.y]==groundTile){
levelData[(int)heroPos.x,(int)heroPos.y]=heroTile;
}else if(levelData[(int)heroPos.x,(int)heroPos.y]==destinationTile){
levelData[(int)heroPos.x,(int)heroPos.y]=heroOnDestinationTile;
}
}
}
}
CheckCompletion();//check if all balls have reached destinations
}
}

In order to get the next position along a certain direction based on a provided position, we use the GetNextPositionAlong method. It is just a matter of incrementing or decrementing either of the indices according to the direction.

If we find a heroTile or ballTile at the given index, we need to set it to groundTile. If we find a heroOnDestinationTile or ballOnDestinationTile then we need to set it to destinationTile.

Level Completion

The level is complete when all balls are at their destinations.

After each successful movement, we call the CheckCompletion method to see if the level is completed. We loop through our levelData array and count the number of ballOnDestinationTile occurrences. If this number is equal to our total number of balls determined by ballCount, the level is complete.

Conclusion

This is a simple and efficient implementation of sokoban logic. You can create your own levels by altering the text file or creating a new one and changing the levelName variable to point to your new text file.

The current implementation uses the keyboard to control the hero. I would invite you to try and change the control to tap-based so that we can support touch-based devices. This would involve adding some 2D path finding as well if you fancy tapping on any tile to lead the hero there.

There will be a follow-up tutorial where we’ll explore how the current project can be used to create isometric and hexagonal versions of sokoban with minimal changes.