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.
1,1,1,1,1,1,1 1,3,1,-1,1,0,1 -1,0,1,2,1,1,-1 1,1,1,3,1,3,1 1,1,0,-1,1,1,1
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.
void ParseLevel(){ TextAsset textFile = Resources.Load (levelName) as TextAsset; string[] lines = textFile.text.Split (new[] { '\r', '\n' }, System.StringSplitOptions.RemoveEmptyEntries);//split by new line, return string[] nums = lines[0].Split(new[] { ',' });//split by , rows=lines.Length;//number of rows cols=nums.Length;//number of columns levelData = new int[rows, cols]; for (int i = 0; i < rows; i++) { string st = lines[i]; nums = st.Split(new[] { ',' }); for (int j = 0; j < cols; j++) { int val; if (int.TryParse (nums[j], out val)){ levelData[i,j] = val; } else{ levelData[i,j] = invalidTile; } } } }
While parsing, we determine the number of rows and columns our level has as we populate our levelData
.
Drawing Level
Once we have our level data, we can draw our level on the screen. We use the CreateLevel method to do just that.
void CreateLevel(){ //calculate the offset to align whole level to scene middle middleOffset.x=cols*tileSize*0.5f-tileSize*0.5f; middleOffset.y=rows*tileSize*0.5f-tileSize*0.5f;; GameObject tile; SpriteRenderer sr; GameObject ball; int destinationCount=0; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { int val=levelData[i,j]; if(val!=invalidTile){//a valid tile tile = new GameObject("tile"+i.ToString()+"_"+j.ToString());//create new tile tile.transform.localScale=Vector2.one*(tileSize-1);//set tile size sr = tile.AddComponent<SpriteRenderer>();//add a sprite renderer sr.sprite=tileSprite;//assign tile sprite tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices if(val==destinationTile){//if it is a destination tile, give different color sr.color=destinationColor; destinationCount++;//count destinations }else{ if(val==heroTile){//the hero tile hero = new GameObject("hero"); hero.transform.localScale=Vector2.one*(tileSize-1); sr = hero.AddComponent<SpriteRenderer>(); sr.sprite=heroSprite; sr.sortingOrder=1;//hero needs to be over the ground tile sr.color=Color.red; hero.transform.position=GetScreenPointFromLevelIndices(i,j); occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict }else if(val==ballTile){//ball tile ballCount++;//increment number of balls in level ball = new GameObject("ball"+ballCount.ToString()); ball.transform.localScale=Vector2.one*(tileSize-1); sr = ball.AddComponent<SpriteRenderer>(); sr.sprite=ballSprite; sr.sortingOrder=1;//ball needs to be over the ground tile sr.color=Color.black; ball.transform.position=GetScreenPointFromLevelIndices(i,j); occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict } } } } } if(ballCount>destinationCount)Debug.LogError("there are more balls than destinations"); }
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.
//... tile.transform.position=GetScreenPointFromLevelIndices(i,j);//place in scene based on level indices //... Vector2 GetScreenPointFromLevelIndices(int row,int col){ //converting indices to position values, col determines x & row determine y return new Vector2(col*tileSize-middleOffset.x,row*-tileSize+middleOffset.y); }
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.
Dictionary<GameObject,Vector2> occupants;//reference to balls & hero //.. occupants.Add(hero, new Vector2(i,j));//store the level indices of hero in dict //.. occupants.Add(ball, new Vector2(i,j));//store the level indices of ball in dict //.. private GameObject GetOccupantAtPosition(Vector2 heroPos) {//loop through the occupants to find the ball at given position GameObject ball; foreach (KeyValuePair<GameObject, Vector2> pair in occupants) { if (pair.Value == heroPos) { ball = pair.Key; return ball; } } return null; }
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
.
private bool IsValidPosition(Vector2 objPos) {//check if the given indices fall within the array dimensions if(objPos.x>-1&&objPos.x<rows&&objPos.y>-1&&objPos.y<cols){ return levelData[(int)objPos.x,(int)objPos.y]!=invalidTile; }else return false; }
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.
void Update(){ if(gameOver)return; ApplyUserInput();//check & use user input to move hero and balls } private void ApplyUserInput() { if(Input.GetKeyUp(userInputKeys[0])){ TryMoveHero(0);//up }else if(Input.GetKeyUp(userInputKeys[1])){ TryMoveHero(1);//right }else if(Input.GetKeyUp(userInputKeys[2])){ TryMoveHero(2);//down }else if(Input.GetKeyUp(userInputKeys[3])){ TryMoveHero(3);//left } }
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.
private Vector2 GetNextPositionAlong(Vector2 objPos, int direction) { switch(direction){ case 0: objPos.x-=1;//up break; case 1: objPos.y+=1;//right break; case 2: objPos.x+=1;//down break; case 3: objPos.y-=1;//left break; } return objPos; }
Before moving hero or ball, we need to clear their currently occupied position in the levelData
array. This is done using the RemoveOccupant
method.
private void RemoveOccupant(Vector2 objPos) { if(levelData[(int)objPos.x,(int)objPos.y]==heroTile||levelData[(int)objPos.x,(int)objPos.y]==ballTile){ levelData[(int)objPos.x,(int)objPos.y]=groundTile;//ball moving from ground tile }else if(levelData[(int)objPos.x,(int)objPos.y]==heroOnDestinationTile){ levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//hero moving from destination tile }else if(levelData[(int)objPos.x,(int)objPos.y]==ballOnDestinationTile){ levelData[(int)objPos.x,(int)objPos.y]=destinationTile;//ball moving from destination tile } }
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.
private void CheckCompletion() { int ballsOnDestination=0; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if(levelData[i,j]==ballOnDestinationTile){ ballsOnDestination++; } } } if(ballsOnDestination==ballCount){ Debug.Log("level complete"); gameOver=true; } }
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.