Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#8.0

A .NET8 Runtime Version Of The 2048 Tile Sliding Game

0.00/5 (No votes)
26 Jul 2024CPOL7 min read 1.8K   27  
A C# .NET8 Console implementation of the 2048 tile sliding game.
An application that's used to illustrate the benefits of using the facilities provided by the .NET framework to produce robust, versatile and easily maintainable code.

Introduction

The 2048 game was originally  written in Javascript but now there are versions in many of the main programming languages. C++ is particularly popular with C# falling some way behind it. That’s surprising because  the .NET8 framework  provides features that are specifically designed to manipulate  collections not least of which is system.Linq, Indexers and Lists. This should make coding the game relatively straight forward as 2048 is all about manipulating a collection of tiles

Playing the Game

The game is played on a 4x4 matrix and is populated with tiles of different values. A move consists of sliding all the tiles in a particular direction either up, down, left or right. Matching tiles that are slid together are promoted to a single tile with a value twice that of their existing value but a tile can only be promoted once on the same move. After every move a new tile is placed randomly in an empty space on the board with a value of either 2 or 4, the relative incidence of these two values is 9:1. Two new tiles are placed randomly on the board at the start of the game. The game is won when a tile gets promoted to a value of 2048. It is lost when there are no spaces on the board and there are no matches in any row or column.

Image 1

By way of an example, the second board above shows the result of sliding the tiles in the first board to the right. The 32 tile in row 0 has simply slid to the right edge of the board and the 16 tile has collided with the 8 tile on row 1, in both rows there are no collisions with matching values so there are no changes in the value of the tiles. On row 2 the situation is different. The 2 in column 2 has collided with the 2 in the last column. This has resulted in the tile in the last column being promoted to a 4 tile and the other matching 2 tile being removed. A similar thing has happened to the other matching pair of 2s. The effect of the rule that a tile can only be promoted once can be seen in the last row. The last two 2 tiles collide and are promoted to a 4 tile, then the existing 4 tile collides with the newly promoted 4 tile but no further promotion occurs. The new tile at row 3 column 0 has been added at a random vacant location after the move was completed.

Some Design Considerations

A useful design technique is to break the game down into its component parts and employ distinct classes to implement the components. Structuring the classes so that they contain related methods with each method having a specific responsibility usually produces clean code with a minimal amount of if statement pollution. The use of Interfaces facilitates testing and allows easy substitution of classes that implement the same interface. Coding to interfaces rather than concrete types is simplified by using the factory methods provided by .NET’s Dependency Injection container. The game’s main component parts can be considered to be

  1. A game manager that drives the game engine
  2. A user interface that enables the game to interact with the user
  3. A game Engine that contains methods used to progress the game.
  4. A data management system that provides structured storage of the tiles and can respond to requests from the game engine for information regarding the relative position of the tiles and their values.

The Game Manager

The GameManager class initialises the game and calls the main game loop within its Play method.

C#
   [Flags]
public enum Direction
{
    Up,
    Left,
    Down,
    Right,
    Exit
}
public class GameManager(IGameEngine gameEngine,
                         IGameGUI consoleGUI) : IGameManager
{
    private readonly IGameEngine gameEngine = gameEngine;
    private readonly IGameGUI consoleGUI = consoleGUI;
    public void StartGame()
    {
        consoleGUI.DisplayBoard();
        gameEngine.Reset();
        gameEngine.AddNewTilesToCollection(2);
        consoleGUI.ShowTiles(0);
        Play();
    }

    public void Play()
    {
        bool isRunning = true;
        int total = 0;
        while (isRunning)
        {

            var direction = consoleGUI.GetNextMove();
            if (direction == Direction.Exit) break;
            (isRunning, int score) = PlayMove(direction);
            total += score;
            consoleGUI.ShowTiles(total);
        }
        consoleGUI.DisplayGameResult(gameEngine.IsWinner);

    }

    private (bool, int) PlayMove(Direction direction)
    {
        if (direction == Direction.Exit) return (false, 0);
        int score = gameEngine.SlideTiles(direction);
        bool isRunning = gameEngine.CompleteMove();
        return (isRunning, score);
    }

}

The User Interface.

The ConsoleGUI class provides user input by returning a member of the Direction enum as the move choice from a switch statement.

C#
   public Direction GetNextMove()
{
    var consoleKey = (Console.ReadKey(true).Key);
    return consoleKey switch
    {
        ConsoleKey.UpArrow => Direction.Up,
        ConsoleKey.DownArrow => Direction.Down,
        ConsoleKey.LeftArrow => Direction.Left,
        ConsoleKey.RightArrow => Direction.Right,
        ConsoleKey.Escape => Direction.Exit,//used to quit app
        _ => Direction.Up
    };
}

In this Console application it is mainly a wrapper for the Console class but it implements the interface IGameGUI so it can be  replaced by a GUI suitable for other types of application

Image 2

The Game Engine

The majority of the game’s implementation lies within the GameEngine class. It depends up on the data management class the TileRepository for setting and getting information about the current state of play and a TileSlider class to handle the mechanics of tile sliding. The game manager’s PlayMove method calls the engine’s SlideTiles method.

C#
//returns the score for the move
public int SlideTiles(Direction direction) =>

  direction switch
  {
      Direction.Up => tileSlider.SlideAllColumns(Direction.Up, tileRepository),
      Direction.Down => tileSlider.SlideAllColumns(Direction.Down, tileRepository),
      Direction.Left => tileSlider.SlideAllRows(Direction.Left, tileRepository),
      Direction.Right => tileSlider.SlideAllRows(Direction.Right, tileRepository),
      _ => throw new ArgumentException("Invalid enum value", nameof(direction))
  };

The CompleteMove method updates the tiles and determines if the game can continue.

C#
public bool CompleteMove()
{
    var emptytileCollection = tileRepository.EmptyTileIndices();
    //if no change since the last move, return true and skip adding a new tile
    if (previousBlankTiles.SequenceEqual(emptytileCollection)) return true;
    if (tileRepository.IsGameWon())
    {
        IsWinner = true;
        return false;
    }
    int tileId = GetNewTileId();
    var newtileValue = GetRandomTileValue();
    tileRepository[tileId] = newtileValue;
    UpdateAllTiles(false);
    return tileRepository.CanPlayContinue();
}

The Tile Slider

The TileSlider class has a method Slide that is called after every move. Each row or column as required is extracted from the tile array and passed to the method as a contiguous (no blanks) list of integers. The slide method compares adjacent tiles for a match. If a match is found, the first tile in the pair is promoted in value and the other tile is set to zero to prevent it from being matched again on the next tile comparison. When all tiles have been compared the blank tiles are removed and the list is padded to the correct length with zeros. The formatted list and slide score is returned as a Value Tuple.

C#
private (List<int> formattedList, int score) Slide(List<int> tileList,
                                                         Direction direction)
{
 //default sliding is left/up
 // so need to reverse the order for right/ down sliding
 bool isReverse = direction == Direction.Right || direction == Direction.Down;
 if (isReverse) tileList.Reverse();
 int slideScore = 0;
 //slide matching values together
 for (int index = 0; index < tileList.Count - 1; index++)
 {
  if (tileList[index] == tileList[index + 1])
  {
   //mark tile for deletion
   tileList[index + 1] = 0;
   tileList[index] += 1;//promote tile
   slideScore += 1 << tileList[index];//no need to use Math.Pow()
   index++;//skip blank tile
  }
 }
 //No spaces allowed between values so remove the blanks created by combining values
 var formattedList = tileList.Where((n) => n != 0).ToList();
 //add zeros to pad the row up to the row length
 formattedList.AddRange(Enumerable.Repeat(0, 4 - formattedList.Count));
 if (isReverse) formattedList.Reverse();
 return (formattedList, slideScore);

}

The Data Management System

The TileRepository Class handles the management of the tile collection. Tile values are stored as powers of 2 with a range of 0-11 with 2^11=2048. The backing store is an int[16] array that uses an indexer to enables row and column coordinates to access the tiles.

C#
    //indexer, this enables tileRepository[row,col] access to the tiles
public int this[int row, int col]
{
    get => tiles[row * 4 + col];
    set => tiles[row * 4 + col] = value;
}

The use of a single dimensional array enables access to the tile values by the powerful system.Linq IEnumerable extension methods. Here are a couple of simple Linq methods that the repository employs

C#
public bool IsGameWon() => tiles.Contains(11);
public bool IsCollectionFull() => tiles.Any((v) => v == 0) is false;

Enumerables are not intrinsically accessible through the use of an index, however, some Linq methods provide one. But they tend not to perform as well in terms of execution time and memory use as an equivalent for loop. So the first method here is preferred to the second.

C#
public IEnumerable<int> EmptyTileIndices()
{

    for (int i = 0;i<tiles.Length;i++)
    {
        if (tiles[i] == 0)
        {
            yield return i;
        }
    }
}
 public IEnumerable<int> EmptyTileIndices()
 {
     return tiles.Select((v, index) => index).Where((i) => tiles[i] == 0);
 }

The repository has a CanPlayContinue method that’s called when a new tile has been added after the completion of a move.

C#
public bool CanPlayContinue()
 => IsCollectionFull() is false || IsMatchOnRows() || IsMatchOnCols();

 private bool IsMatchOnRows()
 {
     for (int r = 0; r < 4; r++)
     {
         for (int c = 0; c < 3; c++)
         {
             if (this[r, c] == this[r, c + 1]) return true;
         }
     }
     return false;

 }
 private bool IsMatchOnCols()
 {
     for (int c = 0; c < 3; c++)
     {
         for (int r = 0; c < r; r++)
         {
             if (this[r, c] == this[r + 1, c]) return true;
         }
     }
     return false;

 }

There is an equivalent single statement  Linq method but it’s 50% slower and three times more memory intensive

C#
public bool CanPlayContinue()
{
  //return true if the collect is not full
  return !IsCollectionFull()
  //Or if there are any possible matches on the rows
  || tiles.Where((v, i) => (i < 15 && tiles[i] == tiles[i + 1] && i / 4 == (i + 1) / 4)
  //Or if there are any possible matches on the cols
  || (i < 12 && tiles[i] == tiles[i + 4])).Any();
 }

Some AI Considerations.

My experience has been that the only way to win consistently at 2048 is to use artificial intelligence(AI). Almost all of the artificial intelligence algorithms require that a large number of move possibilities are considered in as short a time as possible with minimal memory overhead. C# can be used to implement this but the ITileRepository and ITileSliding types need to operate at the binary bit shifting and masking level. Fortunately the game looks like it has been specifically designed to fulfil this requirement. A tile value can be stored as a power of 2 (range 0-11) in a 4 bit nibble. There are 4 tiles in a row or column so they can be accommodated in an unsigned short (2 bytes). There are 4 rows of 4 shorts which means that the whole board can be represented by a single unsigned long(8 bytes). Operating at the binary bit shifting and twiddling level can be challenging. Here is an example that illustrates the main techniques used. At the risk of stating the obvious, a nibble (4 bits ) and can be represented in hexadecimal notation by a single character 0..9A..F so hex 0xF =all 4 bits set=15 in decimal.

C#
    public ulong SetNibbleFromIndex(int index, byte nibble, ulong board)
{
    //the nibble here is a byte with bits 4,5,6,7 cleared(set to 0)
    int shiftCount = index * 4;
    // shift bits up by shiftCount on a ulong where only bits 0,1,2,3 are set
    ulong shifted = (ulong)0xF << shiftCount;
    //invert the bits so only the required nibble's bits are 0
    ulong mask = ~shifted;
    //AND in the mask to clear the nibble's portion of the board's bits
    var cleared = board & mask;
    //move the new nibble as a ulong into position
    var update = ((ulong)nibble) << shiftCount;
    //OR in the new nibble
    return cleared | update;
}

It’s important to test thoroughly methods that use binary operators as they can accept and return all sorts of nonsense without complaining or crashing.

Conclusion

This implementation of the 2048 game illustrates a method of coding that gives rise to lots of classes, interfaces and methods. That’s not to everyone’s taste but it’s hoped that others, who are starting out on their coding journey, may find it useful.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)