I’ve introduced Jelly Wars in my previous post – it’s a strategy board game for Android, similar to Reversi and Go. In this post, I’ll discuss a bit about the development process of the game.
Overview
Jelly Wars was developed from scratch in Unity3d and C#. It consists of a main menu, a game scenes with some pop up dialogues, and an AI mechanism for single player sessions.
Main classes
The game consists of 3 main classes, that are used to manage the flow and state of the game – Cell, Map and GameManager. Cell represents a single cell of the board – it’s owner, whether it’s selected or highlighted, as well as handling its display. Map represents the whole board, with a collection of cells, the sprites to display the various pieces, and the initialization and layout mechanism. GameManager is responsible to manage the turns of the players (invoking the AI as needed), updating the state of the game (the map, UI), etc.
Here’s a high level view of these classes:
public class Cell : MonoBehaviour
{
public int Index;
public Vector2 Location;
private Image m_image;
private int m_owner;
private bool m_selected;
private Color m_hightlightColor = Color.white;
private Animator m_animation;
// Use this for initialization
void Awake()
{
m_image = gameObject.GetComponent<Image>();
m_animation = gameObject.GetComponent<Animator>();
}
public void OnClick()
{
GameManager.instance.SelectCell(this);
}
public int owner
{
get { return m_owner; }
set
{
m_owner = value;
m_animation.SetInteger("owner", m_owner);
UpdateUI();
}
}
public void SetSelection(bool selected)
{
m_selected = selected;
m_animation.SetBool("selected", m_selected);
}
public void SetHighlight(Color? hightlightColor = null)
{
m_hightlightColor = hightlightColor ?? Color.white;
UpdateUI();
}
private void UpdateUI()
{
if (m_owner < Map.instance.playersSprites.Length)
m_image.sprite = Map.instance.playersSprites[m_owner];
else
m_image.sprite = Map.instance.emptySprite;
if (m_image.color != m_hightlightColor)
m_image.color = m_hightlightColor;
}
}
public class Map : MonoBehaviour
{
public static Map instance;
public GameObject cellPrefab;
public Sprite[] playersSprites;
public Sprite emptySprite;
[HideInInspector]
public Cell[,] cells;
private GridLayoutGroup m_gridLayout;
private RectTransform m_transform;
private int m_boardSize;
void Awake ()
{
instance = this;
m_boardSize = GameMode.GetBoardSize();
m_gridLayout = GetComponent<GridLayoutGroup>();
m_gridLayout.constraintCount = m_boardSize;
m_transform = GetComponent<RectTransform>();
cells = new Cell[m_boardSize, m_boardSize];
var index = 0;
for (int y = 0; y < m_boardSize; y++)
{
for (int x = 0; x < m_boardSize; x++)
{
var cellGO = Instantiate(cellPrefab, this.transform);
cellGO.transform.localScale = Vector3.one;
var cell = cellGO.GetComponent<Cell>();
cell.Index = index++;
cell.Location = new Vector2(x, y);
cell.owner = Consts.NoPlayer;
cells[x, y] = cell;
}
}
cells[0, 0].owner = Consts.Player1;
cells[m_boardSize-1, m_boardSize - 1].owner = Consts.Player2;
}
}
public class GameManager : MonoBehaviour
{
public static GameManager instance;
private int m_currentPlayerIndex;
private Cell m_selectedCell;
private bool m_gameOver;
private int m_winner;
private int m_boardSize;
void Awake()
{
instance = this;
m_boardSize = GameMode.GetBoardSize();
m_currentPlayerIndex = Consts.Player1;
}
private void UpdateSelectableCells()
{
foreach (var cell in Map.instance.cells)
{
if (cell.owner == m_currentPlayerIndex)
{
cell.SetHighlight();
}
else if (cell.owner == Consts.NoPlayer && m_selectedCell != null && m_selectedCell.Distance(cell) <= 2)
{
cell.SetHighlight(Color.green);
}
else
{
cell.SetHighlight();
}
}
}
public void SelectCell(Cell cell)
{
if (m_currentPlayerIndex == Consts.Player2 && GameMode.Opponent != Opponent.Player)
return;
SelectCellImpl(cell);
}
private void SelectCellImpl(Cell cell)
{
if (cell.owner == m_currentPlayerIndex) // Selecting our own cell
{
// Unselect previously selected cell
if (m_selectedCell != null)
{
m_selectedCell.SetSelection(false);
}
m_selectedCell = cell;
m_selectedCell.SetSelection(true);
}
else if (cell.owner == Consts.NoPlayer && m_selectedCell != null && m_selectedCell.Distance(cell) <= 2) // Selecting destination
{
cell.owner = m_currentPlayerIndex;
TakeOverAroundLocation(cell.Location, m_currentPlayerIndex);
if (m_selectedCell.Distance(cell) == 2)
{
m_selectedCell.owner = Consts.NoPlayer;
}
if (m_selectedCell != null)
{
m_selectedCell.SetSelection(false);
}
m_selectedCell = null;
if (IsGameOver())
{
Map.instance.DisableAll();
StartGameOverAnimation();
}
else
{
MoveToNextPlayer();
}
}
else // Selecting an opponent's cell - just deselect
{
if (m_selectedCell != null)
m_selectedCell.SetSelection(false);
m_selectedCell = null;
}
UpdateSelectableCells();
}
private void MoveToNextPlayer()
{
m_currentPlayerIndex = (m_currentPlayerIndex + 1)%2;
if (m_currentPlayerIndex == Consts.Player2 && GameMode.Opponent != Opponent.Player)
{
StartCoroutine("AIPlay");
}
}
private IEnumerator AIPlay()
{
// Implement AI logic
}
private void TakeOverAroundLocation(Vector2 location, int currentPlayerIndex)
{
var minX = (int) Math.Max(0, location.x - 1);
var maxX = (int) Math.Min(location.x + 1, m_boardSize-1);
var minY = (int) Math.Max(0, location.y - 1);
var maxY = (int) Math.Min(location.y + 1, m_boardSize-1);
for (int x = minX; x <= maxX; x++)
{
for (int y = minY; y <= maxY; y++)
{
if (Map.instance.cells[x, y].owner != Consts.NoPlayer)
{
Map.instance.cells[x, y].owner = currentPlayerIndex;
}
}
}
}
private bool IsGameOver()
{
// Check if game is over
}
}
As you can see, there’s nothing too complex going on there… I left out a lot of details, but most of them are infrastructure and/or UI work, with nothing exciting going on there.
AI Logic
In order to allow the player to play against the AI, some smarts need to be introduced to the game. I’ve decided to use a minimax algorithm, and settled on A-B Pruning, which decreases the number of nodes the minimax algorithm needs to evaluate, by avoiding following paths that are not going to give better results than previously found.
I did introduce a modification to the algorithm, by allowing it to return a few best moves, and have the AI player pick one of them randomly. The better the AI level, the less options are returned, which means that we choose from a shorter list of best moves – picking 1 of the best 3 moves is better than picking one of the 10 best moves.
I’ve also limited the number of future moves that the AI can evaluate – the easy AI will evaluate 1-2 moves, medium will look at 3 moves ahead, while the hard AI will be able to evaluate 5 moves ahead. Of course, this tweaking also affects performance, since evaluating a lot of moves ahead, when each move has many possibilities, is both memory and time consuming…
For the A-B pruning implementation, I’ve created a separate class to represent the board as an array of int
s, and which can generate all the “child” boards that represent the different possible moves. I’ve made sure to create a pool of all those boards – since there are many possibilities, but we only need a few of them in-memory at any given time.
Finishes
I’ve added a few sound effects to make the UX more responsive, as well as adding some animations to make the whole experience more friendly. Managing the main and in-game menu was a simple task, as well as displaying a short tutorial to the user when he first starts the game.
Summary
All in all, developing the game wasn’t difficult, but required a lot of attention to the details, as well as implementing a lot of ground work and infrastructure, especially since I wasn’t using any of the Unity3d store assets.
The experience has taught me a lot – how to implement the AI of the game, how to make a game end-to-end, the number of finishes and touches that are needed to me it production ready, etc. Looking to the future, I’d probably invest a bit in preexisting Unity3d assets and tools, and avoid creating everything from scratch.