Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / game

Hex 2048 - A Numbers Game - C#2022

5.00/5 (13 votes)
7 Oct 2020CPOL15 min read 24.5K   648  
A graphically bouncy and colorful version of the popular numbers tile-sliding game
This numbers game uses Breadth First Search algorithm to find a path along a changing maze and gather similar tiles. Reactive rumble animation brings life to the pieces that shake and jostle their neighbors.

Image 1

Ka-boom video - collect tiles 'n win!

Malicious Source Code

Contrary to popular belief, the original game of 2048 was not developed by the U.S. Military's research agency Darpa, but was secretly developed at MIT by Gabriele Cirulli. Given the game's addictiveness, however, it has been classified as a weaponized distraction which was unleashed onto unwary teenagers, weary moms and beer chugging undergrad students around the world, giving credence to the unproven allegations that Cirulli was really a C.I.A. operative disguised as a 19 year old university student. And so I, seizing this opportunity to finally impress the members of the Underground Coding Community, and Kiddie Coders alike (while patiently awaiting for my Anonymous photo-ID and membership card to arrive in the mail), shed my responsible software developer identity and set out to build the next meme-worthy beguilement in the form of a more lethal version of this "game" and thereby hope to further embellish what its designer says is:

"part of the beauty of open source software".

You can learn more about playing the game by reading this Wiki article.

Or you might be interested in watching a brief video I made about this project and posted on YouTube.

Disclaimer

Image 2Image 3Image 4

Due to the high Waste-of-Time probability caused by this addictive product, the Health and Safety Board recommends the use of the Healthy-Living Safeguard Timer option which has been provided by the manufacturer to help minimize any adverse effects which may arise due to prolonged use of this product.

The designer of Hex2048, Christ Kennedy, will not be held responsible for any damages to the user's social, marital or professional life.

Use with caution.

Image 5Image 6Image 7

Board

Calling this a "Tile-Sliding-Game" is a bit of a misnomer when you look at the inner workings of this GPA destroying code. This home-wrecker doesn't actually do any 'tile-sliding' because the tiles themselves remain in their alotted places from the time they are initialized until you hear your loved ones slam the front door and drive off without you. The original game's quadrilinear layout made it possible to shuffle the cartesian positions (x, y) of the individual tiles while retaining their values but this version uses a hexagonal layout where the pieces can move in six directions rather than four. Each tile is a member of the classTile class and has a list of pointers to its own neighbors. This facilitates a few things but it means that shuffling them around on the board would be a huge hassle and a waste of those beautiful pointers. Also, because the tiles themselves jiggle and wobble on their respective pedestals, it's easy to assign them a spot and give them a radial-coordinate tether with which to roam while never getting lost or straying too far.

A tricky thing I discovered while writing this app (and was still able to get the basics up and running in under 5 hours - Download Hex_2048_fundamentals.zip - 67 KB file is a snapshot of what I had working at the end of that first night of coding) was that, since the columns are shifted vertically from their neighbors, moving horizontally in the odd columns is different from moving horizontally in the even columns. Have a look at the Move() function below which deals with that issue:

C#
public static Point move(Point pt, int dir)
{
  if (lstPtMoveDir.Count == 0)
  {
    List<Point> lstEven = new List<Point>();
    {
      lstEven.Add(new Point(0, -1));
      lstEven.Add(new Point(1, -1));
      lstEven.Add(new Point(1, 0));
      lstEven.Add(new Point(0, 1));
      lstEven.Add(new Point(-1, 0));
      lstEven.Add(new Point(-1, -1));
    }
    lstPtMoveDir.Add(lstEven);

    List<Point> lstOdd = new List<Point>();
    {
      lstOdd.Add(new Point(0, -1));
      lstOdd.Add(new Point(1, 0));
      lstOdd.Add(new Point(1, 1));
      lstOdd.Add(new Point(0, 1));
      lstOdd.Add(new Point(-1, 1));
      lstOdd.Add(new Point(-1, 0));
    }
    lstPtMoveDir.Add(lstOdd);
  }

  return new Point(pt.X + lstPtMoveDir[pt.X % 2][dir].X,
           pt.Y + lstPtMoveDir[pt.X % 2][dir].Y);
}

after initializing the two Even/Odd Point lists, it returns the changes that need to be made to the calling function in order for the point to more from the input parameter pt in the desired direction dir. There are six directions with zero pointing north and the rest of the directions dialling around clockwise from there. In this game, the odd valued columns are shifted higher than the even numbered columns as you can see below:

Image 8

Find Your Way

The Breadth First Search algorithm is an essential part of this project. Its applications are many and varied. Its fast, easy to implement and there's lots of documentation and tutorials on-line to help you out. We can have a quick look at an example for Hex2048. In the image below, the blue piece at the top (numbered 15) needs to move around the Yellow occupied tiles and reach the far left corner at the bottom (numbered 0). To do so, we start at the destination and travel where we can while leaving sticky notes on tiles as we count the steps we've taken since we left the place we're searching. (In different applications, you'd start from the start location and branch out until you found what you're looking for rather than, as we do here, start from the spot we intend to 'find' because we're not looking for some 'thing' but rather a path to a specific destination.) When you've reached the start location (blue square at the top), that square is then pasted with a sticky-note with the number 15, the number of steps taken to get there. To find your way to the destination square from there, you simply count backwards and travel to any (the only in most cases) square with a sticky note that has a value of one less than the tile you're standing on.

Image 9

Notice when you reach the square numbered six along the path, there are two adjacent squares with the number five on it. The algorithm can choose either one and still get to its destination. Sometimes, like in video games, you may randomly choose from your available options just to keep things fresh. Other times, you just want to zip-it along and choose the first suitable tile you encounter. It all really only depends on your Uber driver, anyway.

The code below is taken from Hex2048 and returns a list of point locations on the board that a piece needs to travel along in order to reach its destination. This implementation works in the reverse order, counting the tiles from start to destination and then winding its way back, then taking the final results and reversing the order of the points to get the final output list.

C#
List<Point> MoveSelectedTile_BFS()
{
  Point ptDestination = ptTileHighLight;
  Point ptStart = ptTileSelected;

  int[,] intSeen = new int[szGame.Width, szGame.Height];
  // init intSeen
  for (int intX = 0; intX < szGame.Width; intX++)
    for (int intY = 0; intY < szGame.Height; intY++)
      intSeen[intX, intY] = -1;

  List<Point> lstQ = new List<Point>();
  lstQ.Add(ptStart);
  intSeen[ptStart.X, ptStart.Y] = 0;
  int intStepsTaken = 0;
  while (lstQ.Count > 0)
  {
    Point ptTile = lstQ[0];
    lstQ.RemoveAt(0);
    intStepsTaken = intSeen[ptTile.X, ptTile.Y];

    for (int intDirCounter = 0; intDirCounter < 6; intDirCounter++)
    {
      Point ptNeaghbour = move(ptTile, intDirCounter);

      if (TileInBounds(ptNeaghbour))
      {
        if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] < 0)
        { // BFS has not seen neaghbour
          if (Board.Tiles[ptNeaghbour.X, ptNeaghbour.Y].Value == 0)
          { // neaghbour is not occupied by a colored tile
            intSeen[ptNeaghbour.X, ptNeaghbour.Y] = intStepsTaken + 1;

            if (ptNeaghbour.X == ptDestination.X &&
              ptNeaghbour.Y == ptDestination.Y)
            { // we have found a path to the destination
              lstQ.Clear();
              goto validPath;
            }
            else
            {
              lstQ.Add(ptNeaghbour);
            }
          }
        }
      }
    }
  }

  return new List<Point>();

validPath:
  intStepsTaken = intSeen[ptDestination.X, ptDestination.Y];

  List<Point> lstSteps = new List<Point>();
  lstSteps.Add(ptDestination);
  Point ptCurrent = ptDestination;
  while (!(ptCurrent.X == ptStart.X && ptCurrent.Y == ptStart.Y))
  {
    for (int intDirCounter = 0; intDirCounter < 6; intDirCounter++)
    {
      Point ptNeaghbour = move(ptCurrent, intDirCounter);
      if (TileInBounds(ptNeaghbour))
      {
        if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] == intStepsTaken - 1)
        {
          lstSteps.Add(ptNeaghbour);
          ptCurrent = ptNeaghbour;
          intStepsTaken--;
          break;
        }
      }
    }
  }

  lstSteps.Reverse();
  lstSteps.RemoveAt(0);

  return lstSteps;
}

The same algorithm is used in a slightly different way when the game needs to test the board's content and remove groups of four or more similar pieces. That function is seeded to start from each tile on the board and branches out to all adjacent tiles that have the same value. When it finds four or more tiles like this, it returns them in a list and they are removed from the board before the same algorithms repeats itself until no groups of similar tiles are found and the game proceeds.

It is much a simpler function. Have a look at the BFS for any given seed tile ptSeed:

C#
List<Point> Tiles_GatherLike_BFS(Point ptSeed)
{
if (!TileInBounds(ptSeed)) return new List<Point>();
int[,] intSeen = new int[szGame.Width, szGame.Height];
int intSeedValue = Board.Tiles[ptSeed.X, ptSeed.Y].Value;
if (intSeedValue <= 0) return new List<Point>();

List<Point> lstQ = new List<Point>();
lstQ.Add(ptSeed);

List<Point> lstRetVal = new List<Point>();

while (lstQ.Count > 0)
{
  Point ptTest = lstQ[0];
  intSeen[ptTest.X, ptTest.Y] = 1;
  lstQ.RemoveAt(0);
  int intTileValue = Board.Tiles[ptTest.X, ptTest.Y].Value;
  if (intTileValue == intSeedValue)
  {
    if (!lstRetVal.Contains(ptTest))
    {
      lstRetVal.Add(ptTest);
      for (int intDir = 0; intDir < 6; intDir++)
      {
        Point ptNeaghbour = move(ptTest, intDir);
        if (TileInBounds(ptNeaghbour))
          if (intSeen[ptNeaghbour.X, ptNeaghbour.Y] == 0)
            if (!lstQ.Contains(ptNeaghbour) && !lstRetVal.Contains(ptNeaghbour))
              lstQ.Add(ptNeaghbour);
      }
    }
  }
}
return lstRetVal;
}

Grumbling and Rumbling

One of the first things you'll notice when you play this game is the way the pieces jostle each other when they move. The effect is pretty cool. As I mentioned earlier, each tile is tethered to its spot by a Radial Coordinate (which is like a Spherical Coordinate but missing the third vertical coordinate Phi). Essentially, it's an arrow with a specific length. The arrow points in the direction in which the tile is shifted away from the tile's fixed central location and the length tells how far it moves in that direction. But the tiles only move a little bit at a time so they need to know by how much and how far to go. At every clock cycle, the magnitude of the radial coordinate is increased until it reaches its limit and then it begins to shorten until it's zero again and the tile is at rest. When it reaches its maximum limit, that limit and the direction of the radial coordinate is used to 'push' the neighboring tiles that are in the direction that it has been shifted. The three neighbors in the general direction of its shift are each imparted with one third of the force it was hit with, and in this way, the motion of the tiles progresses radiating outwards from the source.

Liquids and air all behave in a similar way. Sounds waves are really just atmospheric particles colliding with each other and propagating the force imparted on each of their neighbors. Conservation of energy means that the force each particle feels is distributed across to all its neighbors. Here, we have tiles moving in one of six quantized directions. When they reach the end of that shift, they push one neighbor slightly to the left.

(dir + 5) % 6

and another neighbor slightly to the right:

(dir +1) % 6

and the one neighbor that is in the same direction itself was pushed.

This sounds all fine and good but what happens in implementation is that the shoves that proceed, say to the left, wind their way back the way they came and a harmonic resonance like situation results. Watch this video of the Tacoma Bridge Collapse, if you don't know what I mean. There may not be any gail force winds billowing across the game board here, but it's still a problem. When I was putting this feature together, the slightest jostle from one piece sent the entire board flying off the rack. What I did to solve that problem was add a list of points to each tile for it to keep track of which tiles initiated the wave of motion that is hurtling towards them. When they are struck by a neighbor, that neighbor reports which tile initiated the force hitting them and the tile being hit then looks in its list to see if it has been hit by that tile's impulse recently. If the source-tile's location (ID point) is not in the list, then the tile is hit and adds the source-point to its list. When the tidal force winds its way around the board and tries to hit it again, the force is ignored the second and all subsequent times. Each tile sends forward the same source-tile's ID when it propagates the impulse it felt on to its neighbors and each tile clears its list of source-tile-IDs when it comes to rest.

Resizing PerPixelAlpha Forms

I initially intended to add Dragon sprites on either side of the game. With that in mind, I cut up a Dragon image I downloaded off the internet and made it into a passable sprite. The snake-like dragon I picked was not as conducive to sprite-making as a non-snake-like dragon I could have picked, so the results were not as good as they could have been but the real problems erupted when I started drawing the animated dragon on the Left side of the game. The reason why the Left dragon was so problematic was that the dragon's shape and size varied as it moved. Which, of course was the point of making it into a sprite, but the resultant form image varied in size as well which meant the form had to move to adjust for the changes. Moving the form works fine for smaller sprites like the Jessica Rabbit sprite-form I published some ten years ago but for this interactive game, the results were unacceptable. The game was jumping visibly at a delayed rate after it changed its image and it was so bad that I had to implement a dual-form system that drew the next frame on one form, positioned it then toggled the previous off (Hide()) before instantly toggling the next one on (Show()). The graphics were much better with this implementation but it turns out that the mouse-event triggers were not following the plan and the game became unresponsive. As a consequence of that, I wrote a few lines of code that were called every game loop (on a timer) and tested the mouse's position and button states before deciding which events to call instead of letting the two jumping forms jostle each other for the privilege and ignoring their duty to report mouse-events at all in the bitterness of their fight. This was working pretty good and the results were better but there were so many complications and the code got so ugly that I looked at my dragons (two mirrors of the same sprite) and decided it just wasn't worth it. I set the form's size to a constant value which was big enough to accommodate both dragons at their worst and looked at what I had done and decided it was good.

But not good enough.

The form's larger size reduced the game's speed and the dragons were just not worth it. So I trashed them. It only took me ten minutes to re-jig the whole project without those ugly dragons and we have what we have now... much much better.

Sparkles - Stars, Spikes and Caltrops

Stars, spikes and caltrops are the three different shapes which fly out in multi-colors whenever the game gets excited. They're all drawn dynamically the first time the game is launched and then their variously colored and rotated versions are all stored on the harddrive in a binary stream file. A single algorithm draws all three shapes which only differ in the number of 'spikey' things sticking out of them. Stars have 5 points, caltrops (not sure why I called them that?!?) have four and spikes only 2. They each have an inner and outer radius and the points are generated by going around a central point and setting points at alternating radii from it. These images are rotated, as I said, and stored in sequence on the file. As they are stored, their addresses are recorded in a list and the file size is used as the start of the image index where all those addresses that were accumulated in a list are recorded in the same sequence as their images. At the end of all this, the very last thing recorded on the file, is a long integer which tells you where the index starts. So to find a specific image, you plug the shape, color and angle through this function:

C#
static long ImageAddress(enuSparkleShapes eShape, enuSparkleColors eColor, int intRotation)
{
  long lngIndexAddress
      = lngFileIndexAddress
        + (((int)eColor
              * ((int)enuSparkleShapes._numSparkleShapes
              * NumRotationPerQuarterTurn))
           + ((int)eShape
              * NumRotationPerQuarterTurn)
           + intRotation) * SizeLongInteger;
  fs.Position = lngIndexAddress;
  return (long)formatter.Deserialize(fs);
}

It calculates the location of the desired image's Index by figuring out how many indices come before it, multiplying that value by the amount of bytes it takes to store a single long integer in the stream and adding their product to the Address of the start of the Index (that last long integer we wrote at the end of the file).

So, it measures the length of the file. Moves back the number of bytes to store a single long integer on my harddrive, 58 bytes (yea!? 58... I was surprised too since a long integer is only 8 bytes ??). There it reads the long integer and knows where in the Index starts. It moves forward from the start of the index and locates the address of the image we're looking for. Reads the long integer and uses it as the address of the bitmap.

Confused?

Image 10

Alternately, there is an advantage in storing the index on a separate file which is that you can add images and grow both your index and your images list on two separate files. In this case, we're talking about tiny images of colored stars that only take seconds to reproduce, so even if I change my mind and add that neglected 3-pointed ninja-star to the list of 'sparkle shapes', it is no great cost to delete the existing file and rebuild it.

Having both the images and the index on a single file keeps things neat.

Since a different OS may give you different results, the SizeLongInteger variable needs to be evaluated. Here is the function which determines how many bytes it takes to store a long integer on your harddrive:

C#
static long SizeLongInteger = 0;
static void SizeLongInteger_Measure()
{
  string strTempFilename = "DoNotTryThisAtHome.bin";
  FileStream fsTemp = new FileStream(strTempFilename, FileMode.Create);
  fsTemp.Position = 0;

  formatter.Serialize(fsTemp, (long)1);
  SizeLongInteger = fsTemp.Position;

  fsTemp.Close();

  System.IO.File.Delete(strTempFilename);
}

Debugger and Binary Strings

When I was dealing with those mouse event issues, I had to figure out how the MouseEventArgs were encoded. They seem to be stored in a long integer. I converted the MouseButtons value into a long integer and discovered that the 20th bit was set when the left mouse button was down. Likewise for the middle and right buttons, 22nd and 23rd, respectively. To help along the way, I wrote a few functions that convert integer values (byte, short, int and long) into strings to make it easier to look and debug them.

Since the >> and << bitwise shift operands return integer values, the workhorse here takes in an integer and spits out a 32 character string of 1s and 0s.

C#
public static string intToString(int intIn)
{
  string strRetVal = "";
  for (int intBitCounter = 0; intBitCounter < 32; intBitCounter++)
  {
    char chr = intIn % 2 == 0
              ? '0'
              : '1';
    strRetVal = chr.ToString() + strRetVal;
    intIn = intIn >> 1;
  }
  return strRetVal;
}

It's convenient, and I'm glad I wrote it. I'm sure it will come in handy again soon enough.

Why Bother?

There are hundreds of versions of this game available on-line, so why would anyone write their own?

I was playing a version similar to this one (Hexagonal board) on my phone and got annoyed with all the ads. I loved the game so much I actually considered doling out the $5.00 payment to get rid of the ads but then reconsidered and decided it would be more cost-efficient to spend a week writing my own... I actually did press the option to pay to get rid of the ads but it didn't work and bogged my wireless down so badly, it forgot it was a telecommunication device when the ringtone sounded and I nearly missed a call trying to get it to morph from a slow ad-riddled game back into phone.

It was a wrong number and that sent me into a downward spiral of coding energy... and the only way out of a downward spiral like that is to code, right? So that's what I did.

I started last Sunday at 19h and had a working game before midnight. It was so motivating and encouraging to get such quick results that I spent the rest of the week improving the graphics.

Coding really is a great way to spend your time, but really ... I just hate ads.

History

  • 28th September, 2020: Initial version
  • 2nd October, 2020: Updated software - first instance of Final Tile wasn't disappearing
  • 4th October, 2020: Added original source code - I wrote in under 5 hours.
  • 7th October, 2020: Fixed High Score file-save - added Timer count-down option
  • 19th March, 2022: Changed the way the scoring works to incentivize gathering more pieces in single collection
  • 5th June, 2023: Added Challenges
  • 13th April, 2024 : changed the rules . player gets 'free life' when final tiles (4096 + 2048) are removed . 
  • 8th August, 2024 : debugged a problem . user earns challenge fails with high earning gathered sets 

License

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