Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A Walkthrough to Implement 2048 Game

0.00/5 (No votes)
4 Mar 2017 1  
An introduction to make a simple 2048 console application in C#

Introduction

In this topic, I would like to take a few minutes to talk about the implementation of a simple 2048 game in console application, including a couple of useful tips that can make the implementation easier.

The project targets .NET Core and .NET Standard 1.6. However, it can be easily adapted to other .NET Framework versions. The full source code can be viewed on GitHub.

Background

The 2048 implementation was initially created as an example project of Heuristic Suite - a generic implementation of A* algorithm, and using the algorithm to reach the best score as much as possible. However, before starting the example project, verifying the game logic in the actual game is necessary, which becomes the source of this article.

Game Loop

Everything starts from the game loop. The loop is quite simple as many people may have known. Initially, we have 2 numbers on the board whose positions are randomized. When numbers are moved towards one of 4 directions, a new number will be added, which can be 2 or 4. In the mean time, a pair of the same number on the same row or column will be merged and summed up. If the board is full and no more moves are available, the game ends.

Game Loop

The Board

The board status is stored in a two-dimension integer array where 0 represents empty slot.

var array = new int[BoardSize][BoardSize];

Then a Position structure is defined for each position on the board where Empty represents the position that does not exist.

public struct Position : IEquatable<Position>
{
    public static readonly Position Empty = new Position(int.MinValue, int.MinValue);
    
    public int Col { get; private set; }
    public int Row { get; private set; } 
    public bool IsEmpty { get; private set; }
    
    public Position(int col, int row)
    {
        this.Col = col;
        this.Row = row;
        this.IsEmpty = col == int.MinValue && row == int.MinValue;
    }
    
    public bool Equals(Position other)
    {
        return this.Col == other.Col && this.Row == other.Row;
    }
    
    public override int GetHashCode()
    {
        return this.Col ^ (0 - this.Row);
    }
}

Now we can put a position-randomized number on the board by the following implementation. Caller can decide what number is to be put on the board or leave the number randomized.

private static readonly Random rand = new Random(Environment.TickCount);

public const int BoardSize = 4

public static Position PutNumberOnArray(int[][] board, int? number = null)
{
    if (CountPlaces(board) == BoardSize * BoardSize) // no empty place to put new number
        return Position.Empty;
        
    var pos = new Position(rand.Next(BoardSize), rand.Next(BoardSize));
    
    while (board[pos.Row][pos.Col] != 0)
        pos = new Position(rand.Next(BoardSize), rand.Next(BoardSize));
        
    if (number == null)
        number = rand.Next(4) != 1 ? 2 : 4; // 3/4 chance to get 2, 1/4 chance to get 4
        
    board[pos.Row][pos.Col] = number.Value;
    
    return pos;
}

Because the initial board status is required to have two numbers, we implement the following method which calls PutNumberOnArray twice.

public static int[][] InitalizeArray()
{
    var board = Enumerable.Repeat(BoardSize, BoardSize).Select(size => new int[size]).ToArray();
    
    PutNumberOnArray(board, 2);
    PutNumberOnArray(board, 2);
    
    return board;
}

The Four Moves

Now let us take a look at how we move the numbers in four directions on the board, which is the most important part of the implementation.

Moving the numbers on the same row or column can be broken down into two phases:

  1. Merge and sum up every pair of same nearby numbers, or same numbers that are not interrupted by other numbers.
  2. Move remaining numbers toward the direction.

Example: Moving Left

MoveLeft is the most simple one to implement. We have the following method that operates a row where phase one is scanning and summing up every pair of same numbers, and phase two is moving the remaining numbers to the left. (Notice that the type of argument is IList(T) instead of the actual array. This little flexibility will help us greatly later.)

public static bool MoveLeft(IList<int> row)
{
    // Phase 1: merge numbers
    var col = -1;
    var length = row.Count;
    var modified = false;
    
    for (var y = 0; y < length; y++)
    {
        if (row[y] == 0)
            continue;
        if (col == -1)
        {
            col = y; // remember current col
            continue;
        }
        if (row[col] != row[y])
        {
            col = y; // update
            continue;
        }
        if (row[col] == row[y])
        {
            row[col] += row[y]; // merge same numbers
            row[y] = 0;
            col = -1; // reset
            modified = true;
        }
    }
    // Phase 2: move numbers
    for (var i = 0; i < length * length; i++)
    {
        var y = i % length;
        
        if (y == length - 1) continue;
        if (row[y] == 0 && row[y + 1] != 0) // current is empty and next is not 
        {
            row[y] = row[y + 1]; // move next to current
            row[y + 1] = 0;
            modified = true;
        }
    }
    return modified;
}

MoveRight is very similar to MoveLeft where two for-loops operate oppositely.

public static bool MoveRight(IList<int> row)
{
    // Phase 1: merge numbers
    var col = -1;
    var length = row.Count;
    var modified = false;
    
    for (var y = length - 1; y >= 0; y--)
    {
        if (row[y] == 0)
            continue;
        if (col == -1)
        {
            col = y; // remember current col
            continue;
        }
        if (row[col] != row[y])
        {
            col = y; // update
            continue;
        }
        if (row[col] == row[y])
        {
            row[col] += row[y]; // merge same numbers
            row[y] = 0;
            col = -1; // reset
            modified = true;
        }
    }
    // Phase 2: move numbers
    for (var i = length * length - 1; i >= 0; i--)
    {
        var y = i % length;
        
        if (y == 0) continue;
        if (row[y] == 0 && row[y - 1] != 0) // current is empty and next is not 
        {
            row[y] = row[y - 1]; // move next to current
            row[y - 1] = 0;
            modified = true;
        }
    }
    return modified;
}

MoveUp and MoveDown are a bit interesting. Before we move on, let us think how we reuse MoveLeft and MoveRight methods to reduce code redundancy. Since the only difference is that the move is either vertical or horizontal, it seems very likely.

The fact is, if we can access all elements on same column as a collection, the implementation will be very easy. Therefore, we have the following class that implements IList(T) interface.

/// <summary>
/// A wrapper that enables user to access specific column in a two-dimension array as a collection.
/// </summary>
/// <typeparam name="T">The type of element.</typeparam>
public class ColumnWrapper<T> : IList<T>, IReadOnlyList<T>
{
    private readonly T[][] array;
    private readonly int col;
    
    public ColumnWrapper(T[][] array, int col)
    {
        this.array = array;
        this.col = col;
    }
    
    public T this[int index]
    {
        get { return this.array[index][col]; }
        set { this.array[index][col] = value; }
    }
    
    public int Count
    {
        get { return this.array.Length; }
    }
    
    public bool IsReadOnly
    {
        get { return false; }
    }
    
    public bool Contains(T item)
    {
        return this.array.Select(row => row[col]).Contains(item);
    }
    
    public int IndexOf(T item)
    {
        var ec = EqualityComparer<T>.Default;
        
        foreach (var i in Enumerable.Range(0, this.array.Length))
            if (ec.Equals(this.array[i][col], item))
                return i;
                
        return -1;
    }
    
    public void CopyTo(T[] array, int arrayIndex)
    {
        this.ToArray().CopyTo(array, arrayIndex);
    }
    
    public IEnumerator<T> GetEnumerator()
    {
        return this.array.Select(row => row[col]).GetEnumerator();
    }
    
    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator();
    }
    
    #region Not Supported Interface Methods
    
    // Omitted
    
    #endregion
}

With ColumnWrapper class coming to help, MoveUp and MoveDown become quite simple as you can see below.

public static bool MoveUp(int[][] array, int col)
{
    return MoveLeft(new ColumnWrapper<int>(array, col));
}

public static bool MoveDown(int[][] array, int col)
{
    return MoveRight(new ColumnWrapper<int>(array, col));
}

Four moves that operate the entire board are now ready to serve. Each method accepts an array of boolean enabling us to quickly learn which row or column has been modified and whether the board is changed after the move is done.

public static void MoveUp(int[][] array, bool[] rowStates)
{
    for (var col = 0; col < BoardSize; col++)
        rowStates[col] = MoveUp(array, col);
}

public static void MoveDown(int[][] array, bool[] rowStates)
{
    for (var col = 0; col < BoardSize; col++)
        rowStates[col] = MoveDown(array, col);
}

public static void MoveLeft(int[][] array, bool[] colStates)
{
    for (var row = 0; row < BoardSize; row++)
        colStates[row] = MoveLeft(array[row]);
}

public static void MoveRight(int[][] array, bool[] colStates)
{
    for (var row = 0; row < BoardSize; row++)
        colStates[row] = MoveRight(array[row]);
}

Game Over Check

There are various ways to check whether the game has reached the end because of no more valid moves. In this implementation, we adopt the most simple way in which we copy the original board state to another instance and check if the four moves can modify the new array.

public static IEnumerable<MoveDirection> GetNextAvailableMoves(int[][] current)
{
    var copied = Clone(current); // create a copy
    var states = new bool[BoardSize];
    
    MoveUp(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
        
    MoveDown(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Down;
        
    MoveLeft(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
        
    MoveRight(copied, states);
    
    if (states.Contains(true)) // if any col/row is modified.
        yield return MoveDirection.Up;
}

Because an empty slot on the board guarantees at least two valid moves, the game over check will only occur when the board is full.

Main Program

With all preparations above, finally we can work on the game itself. Following is the implementation of game loop that we have discussed at the beginning.

public class Program
{
    public static void Main(string[] args)
    {
        var key = default(ConsoleKey);
        var previous = Helper.InitalizeArray();
        var newNumPos = Position.Empty;
        var colStates = new bool[Helper.BoardSize]; // remember modified cols
        var rowStates = new bool[Helper.BoardSize]; // remember modified rows
        
        do
        {
            Console.Clear();
            
            Array.Clear(colStates, 0, Helper.BoardSize);
            Array.Clear(rowStates, 0, Helper.BoardSize);
            
            var current = Helper.Clone(previous); // create a copy
            
            Move(key, current, colStates, rowStates);
            
            if (colStates.Contains(true) || rowStates.Contains(true)) // if any col/row is modified.
                newNumPos = Helper.PutNumberOnArray(current);
            else
                newNumPos = Position.Empty;
                
            Print(current, previous, newNumPos);
            
            // if no new number is added
            if (newNumPos.IsEmpty && !Helper.GetNextAvailableMoves(current).Any())
            {
                Console.WriteLine("Game Over"); break;
            }
            previous = current;
        }
        while ((key = Console.ReadKey(true).Key) != ConsoleKey.X);
        Console.ReadKey(true);
    }
    
    private static void Move(ConsoleKey key, int[][] array, bool[] colStates, bool[] rowStates)
    {
        switch (key)
        {
            case ConsoleKey.UpArrow:
                Helper.MoveUp(array, rowStates);
                break;
            case ConsoleKey.DownArrow:
                Helper.MoveDown(array, rowStates);
                break;
            case ConsoleKey.LeftArrow:
                Helper.MoveLeft(array, colStates);
                break;
            case ConsoleKey.RightArrow:
                Helper.MoveRight(array, colStates);
                break;
        }
    }
    
    public static void Print(int[][] current, int[][] previous, Position newNumPos)
    {
        // Omitted. You may make it better-looking on your own.
    }
}

And that is all! Here is how the application looks:

Game Loop

Further Tasks

So far we have done the most simple implementation of 2048 game and all necessary infrastructure. However, there are a few tasks that can make the implementation better, including:

  • Scoring
  • Move Animations
  • Undo Mechanism

History

  • 2017-03-03 Initial post
  • 2017-03-05 Added Move Left example figure

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here