Tutorials‎ > ‎

Console 2048 the Game using Go Programming Language

posted Aug 25, 2014, 12:40 AM by Arden Sagiterry Setiawan   [ updated Aug 15, 2016, 11:26 PM by Surya Wang ]

2048 is a single-player puzzle game created in March 2014 by 19-year-old Italian web developer Gabriele Cirulli, in which the objective is to slide numbered tiles on a grid to combine them and create a tile with the number 2048. It is inspired by the game Threes made by game designer Asher Vollmer, illustrator Greg Wohlwend, and composer Jimmy Hinson. The game has been a major hit in web and mobile format, with many derivatives created following the popularity.

Now comes Go. Go is a powerful programming language developed by Google. While thriving as system programming language, Go is generic enough that we can use it for various usage outside operating system domain. Go is, by design, more closely resembling a functional programming language like C rather than object-oriented ones like Java. However, Go has all the good stuff belonging to object-oriented languages these days, like garbage collection and dynamic-typing capabilities. This makes Go a fun and easy language to code.

In this tutorial, we will learn how to use Go Programming Language to create a console-based version of 2048. The rules and concept will follow the game 2048. As such, the completed work will be used strictly as a learning tool. All creatives license belongs to Gabriele Cerulli.

This tutorial assumes that you have a basic understanding of programming in general, especially on the concept of arrays and repetition. Explanations will focus on how to use Go syntax to implement game concept. This tutorial will not explain each usage of Go keywords found in the codes, rather it will explain the logic behind how each functions were written and what we hoped to do with them.

Requirements

The following applications will be needed in your system to follow through this tutorial.

Core Game Concept

Let's go through the game concept in case you haven't ever played the game yet. 2048 is about moving numbers in a 4x4 board. Each time we slide to one of the four cardinal directions, the numbers will be combined unto each other depending on sliding direction. If the numbers are the same (for example, 2 with 2) then it will be added together into one number which is the sum of both number. On each turn, a number (2) will be placed randomly in any available grid, allowing for more combination.

What we want our game to do is as follows.
  1. Accept arrow keys as input from keyboard to determine the direction of number combination
  2. Combine all the matching numbers based on the direction
  3. If there is a remaining space in the board, randomly place a "2" in it.

The goal of the game is to get the number 2048. Players can only make a "move" (choosing a direction for combination) if there are matching numbers in that direction or there is some space to that the numbers can be moved to the side corresponding to chosen direction. If a player can not make any move, then they lose the game.

Initialization

Let's start by creating a Go code file and declare our main package. In the meantime let's import all the necessary stuff as well. Of important notice is the usage of Termbox package. We will use this package to process output to console as well as capturing arrow keys from keyboard. By default, Go can't capture arrow keys. Termbox allows Go to do it by capturing it directly from event stack.

package main

import (
	"github.com/nsf/termbox-go"
	"math/rand"
	"strconv"
	"time"
)
	

It would be good to add some constants. This way, rather than weird number codes, we can use labels. This will come in handy later on especially for status flag.

//constants
const UP int = 1
const DOWN int = 2
const LEFT int = 3
const RIGHT int = 4
const WIDTH int = 4
const HEIGHT int = 4
const WIN int = 1
const LOSE int = 2
const NEUTRAL int = 3
	

Some global variables would be good too.

//game logic
var board [WIDTH][HEIGHT]int
var free []int
var status int
var score int
	

We are going to use multi-dimensional array to implement the 4x4 grids of 2048. Although we can use single-dimensional array, it is more natural to use multi-dimensional array especially in later calculations. You might noticed that we also created a single-dimensional slice named "free". This slice will be used to store list of available position on the board where we can put our next "2" number. We use slice rather than array because we might want to delete items inside it, which we can't do with static-length type like array.

Status and score is self-explanatory. Status contains the value of our three game status constants (WIN, LOSE, and NEUTRAL). Score contains the score of our game which increases each time we successfully combine numbers.

Utilities

Before we delve into our main code, let's create some useful functions to aid us later on.

func findElement(arr []int, ele int) (int, bool) {
	for i := range arr {
		if arr[i] == ele {
			return i, true
		}
	}

	return -1, false
}
	

findElement is a handy little snippets which allows us to search an element inside an array. It simply iterates the array and return the index if the element is found. If it doesn't found the element, it will simply move out of the for loop and return -1. The boolean true and false allows for simpler validation whether the element is found or not: we returns true if the element exists and false if it doesn't.

func thousandSeparator(num int) string {
	var numstr string = strconv.Itoa(num)
	var result string
	var count int

	l := len(numstr)

	if l > 3 {
		for i := l - 1; i >= 0; i-- {
			if count >= 3 {
				result = string(numstr[i]) + "," + result
				count = 0
			} else {
				result = string(numstr[i]) + result
			}

			count++
		}

		return result
	} else {
		return numstr
	}
}
	

thousandSeparator takes an integer and returns a string containing that integer separated with comma thousand separator (,). The logic is actually simple: take a number, convert it into string with strconv.Itoa, and then put iterate through each characters in that string and put it into a new result string. Each three iterations we simply add additional comma when we put the character into result string.

Game Logic

Now we're finally going to codify our game concept.

func initBoard() {
	rand.Seed(time.Now().UTC().UnixNano())

	free = make([]int, 0)

	for i := 0; i < WIDTH*HEIGHT; i++ {
		free = append(free, i)
	}

	status = NEUTRAL

	score = 0

	getNext()
	getNext()
}
	

First we will create an initialization function which set our game to a fresh start. initBoard starts our game by preparing the seed for our randomization engine, putting status to NEUTRAL position, and initializing game score to 0. We also initialize the "free" slice by putting the number 0 to 15. Each number represents a coordinate in our 4x4 game grids. Lastly we call getNext function twice to put 2 random "2" numbers in the board as a starting point. We'll cover the getNext function after this.

func getNext() {
	l := len(free)

	if l > 0 {
		//create a copy of "free" slice, name it "shuffled"
		shuffled := make([]int, l)
		copy(shuffled, free)

		//shuffle the "shuffled" slice and then take the first element as the next available space
		for i := l - 1; i > 0; i-- {
			j := rand.Int() % (i + 1)
			placeholder := shuffled[i]
			shuffled[i] = shuffled[j]
			shuffled[j] = placeholder
		}

		num := shuffled[0]

		board[num%4][num/4] = 2

		//find num in "free" slice and delete it
		idx, found := findElement(free, num)

		if found {
			free = append(free[:idx], free[idx+1:]...)
		}
	}
}
	

What getNext do is actually simple: randomly find an available space in the grid and then put "2" in it. The way we achieve it is rather peculiar in the name of performance. The first thing that comes to mind might be to random a number and then check whether the board space in that number is occupied or not. If that space is occupied, random a new number and recheck. Keep doing that until we get a free space. Unfortunately, while that algorithm is sound, it is highly inefficient in run time. It might even potentially creates a forever loop! Now, what we do is making a shuffle algorithm isntead.

The idea of shuffling is easy. We just have to have a list of available spaces beforehand, shuffle it, and then take the first element in the shuffled list. You can see the algorithm in the code above. First we create a new slice "shuffled" containing data from our "free" slice. The reason to create this new slice is that we don't want our "free" slice to change in the shuffle process. Next, using iteration, we shuffle that slice. After that we just take the first element of "shuffled" slice. That is the next available space. Now we just need to put the number "2" in the board space corresponding to that space. We achieve that by calculating that the x coordinate is num%4 and the y coordinate is num/4.

In the last part of this function we search for the index of the new space in our "free" slice. Once we found the index, we deleted that item from our "free" slice using append trick. See the last three lines in above code to see how it is done.

func checkStatus() int {
	//check win condition - get 2048
	for y := 0; y < HEIGHT; y++ {
		for x := 0; x < WIDTH; x++ {
			if board[y][x] == 2048 {
				return WIN
			}
		}
	}

	//check lose condition - no more moves
	if len(free) == 0 {
		//check horizontal collapse
		for y := 0; y < HEIGHT; y++ {
			for x := 0; x < WIDTH-1; x++ {
				if board[x][y] == board[x+1][y] {
					return NEUTRAL
				}
			}
		}

		//check vertical collapse
		for x := 0; x < WIDTH; x++ {
			for y := 0; y < HEIGHT-1; y++ {
				if board[x][y] == board[x][y+1] {
					return NEUTRAL
				}
			}
		}

		return LOSE
	}

	return NEUTRAL
}
	

As the name suggests, checkStatus checks the status of our game. First it check each grid of our 4x4 board if it has any containing 2048. If there is, we returns the constant WIN. Next we check the lose condition, but only if there is no more free space availables for a new "2" number (if there is at least one space available, the player will always be able to make a move). We simply iterates each grids and then check each pair of grids. If there is at least one pair of adjacent grid which is of same number, then it is a valid move and the player can still use it, so we return NEUTRAL. However, if there is no adjacent pair to combine, we return LOSE. The last line simply return NEUTRAL if the win condition hasn't been achieved yet but the "free" slice is not empty yet.

type Cell struct {
	value int
	x     int
	y     int
}

func changeBoard(direction int) {
	var empty []int = make([]int, 0, 4)
	var cell Cell
	var cellCheck bool = false
	var valid bool = false

	switch direction {
	case UP:
		for x := 0; x < WIDTH; x++ {
			//collapse
			cellCheck = false
			for y := 0; y < HEIGHT; y++ {
				if cellCheck && cell.value == board[x][y] {
					score += cell.value + board[x][y]
					board[cell.x][cell.y] = cell.value + board[x][y]
					board[x][y] = 0
					cellCheck = false
					valid = true
				} else if board[x][y] != 0 {
					cell = Cell{
						value: board[x][y],
						x:     x,
						y:     y,
					}
					cellCheck = true
				}
			}

			//rearrange
			empty = empty[:0]
			for y := 0; y < HEIGHT; y++ {
				if board[x][y] == 0 {
					empty = append(empty, y)
				} else if len(empty) > 0 {
					board[x][empty[0]] = board[x][y]
					board[x][y] = 0
					empty = append(empty, y)
					empty = empty[1:]
					valid = true
				}
			}
		}
	case DOWN:
		for x := 0; x < WIDTH; x++ {
			//collapse
			cellCheck = false
			for y := HEIGHT - 1; y >= 0; y-- {
				if cellCheck && cell.value == board[x][y] {
					score += cell.value + board[x][y]
					board[cell.x][cell.y] = cell.value + board[x][y]
					board[x][y] = 0
					cellCheck = false
					valid = true
				} else if board[x][y] != 0 {
					cell = Cell{
						value: board[x][y],
						x:     x,
						y:     y,
					}
					cellCheck = true
				}
			}

			//rearrange
			empty = empty[:0]
			for y := HEIGHT - 1; y >= 0; y-- {
				if board[x][y] == 0 {
					empty = append(empty, y)
				} else if len(empty) > 0 {
					board[x][empty[0]] = board[x][y]
					board[x][y] = 0
					empty = append(empty, y)
					empty = empty[1:]
					valid = true
				}
			}
		}
	case LEFT:
		for y := 0; y < HEIGHT; y++ {
			//collapse
			cellCheck = false
			for x := 0; x < WIDTH; x++ {
				if cellCheck && cell.value == board[x][y] {
					score += cell.value + board[x][y]
					board[cell.x][cell.y] = cell.value + board[x][y]
					board[x][y] = 0
					cellCheck = false
					valid = true
				} else if board[x][y] != 0 {
					cell = Cell{
						value: board[x][y],
						x:     x,
						y:     y,
					}
					cellCheck = true
				}
			}

			//rearrange
			empty = empty[:0]
			for x := 0; x < WIDTH; x++ {
				if board[x][y] == 0 {
					empty = append(empty, x)
				} else if len(empty) > 0 {
					board[empty[0]][y] = board[x][y]
					board[x][y] = 0
					empty = append(empty, x)
					empty = empty[1:]
					valid = true
				}
			}
		}
	case RIGHT:
		for y := 0; y < HEIGHT; y++ {
			//collapse
			cellCheck = false
			for x := HEIGHT - 1; x >= 0; x-- {
				if cellCheck && cell.value == board[x][y] {
					score += cell.value + board[x][y]
					board[cell.x][cell.y] = cell.value + board[x][y]
					board[x][y] = 0
					cellCheck = false
					valid = true
				} else if board[x][y] != 0 {
					cell = Cell{
						value: board[x][y],
						x:     x,
						y:     y,
					}
					cellCheck = true
				}
			}

			//rearrange
			empty = empty[:0]
			for x := WIDTH - 1; x >= 0; x-- {
				if board[x][y] == 0 {
					empty = append(empty, x)
				} else if len(empty) > 0 {
					board[empty[0]][y] = board[x][y]
					board[x][y] = 0
					empty = append(empty, x)
					empty = empty[1:]
					valid = true
				}
			}
		}
	}

	free = free[:0]
	for y := 0; y < HEIGHT; y++ {
		for x := 0; x < WIDTH; x++ {
			if board[x][y] == 0 {
				free = append(free, y*HEIGHT+x)
			}
		}
	}

	if valid {
		getNext()
	}
}
	

Okay, this part might look a tad too long, but believe me it is actually quite simple.

First, we create a struct named "Cell" which we will use to store the value and coordinate of an item in our 4x4 board.

Next, we create the changeBoard function; this is the meat of our program. This function will be called each time the player makes a move. Basically, this function is the one which combines the numbers, calculate scores, and getting the board ready for next turn. We start by creating four variables: an array named "empty", a Cell struct variable which we will use later as placeholder, and two boolean variables used as flag.

changeBoard accept an integer named "direction". This will receive one of the four constants denoting arrow keys: UP, DOWN, LEFT, and RIGHT. Each of the four direction has its own algorithm, thus we use switch to determine our course of action based on the value of "direction" argument. You can see that each case of the switch structure contains the four direction constants.

Each direction actually has similar algorithm, only with a slight tweak. Let's focus on UP direction first. In actuality we have two things going on each time a turn is made. First, we combine all same numbers into a new number, and then we tidy up the grids so that all numbers will be placed on the direction of choice. Let's say that we "push" all the numbers to that direction.

The first task is achieved with the help of "cellCheck" boolean. At first, cellCheck is false. Then we iterate either the columns or rows on the grid based on direction. In this case, since we're taking UP (vertical combining) first, the outer for iteration is the x coordinate (columns) and the inner one is the y coordinate (rows). In the case of horizontal combining (LEFT and RIGHT), the outer for would be y coordinate and the inner for would be x coordinate (check the code above to see it more clearly). Back to UP, on each column iteration "cellCheck" is re-initialized into false. Then we start the row iteration from top (index 0). On each grid first we check whether "cellCheck" is true AND whether the value of "cell" variable is the same with the current grid. If it is, then put the sum of value in current grid and "cell" into the coordinate of "cell", combining the two numbers in the position saved in "cell". If it is not, then we check whether this grid is empty or not (empty grid has 0 value). If the grid is not empty, we put the grid's value and coordinate into "cell" and put "cellCheck" into true. This tell the program that we are ready to combine numbers. Note that each column iteration doesn't guarantee any number combination. That would depend on whether there really is two same number or not.

The second step is to simply push all number positions to the direction of choice. Actually we can use algorithm similar to bubble sort to do this, but we chose to use an unorthodox method. We use a slice named "empty", which is initialized to 0 elements on each outer for iteration similar to "cellCheck". Because we chose UP, we start the inner iteration from top. When we choose DOWN we will iterate from the bottom of grid. LEFT and RIGHT would be different as well (check the code for further assurance). Now, if there is an empty grid on iteration, we put that grid's position on the "empty" slice. The concept is like a queue: when we found a non-empty grid, we take the first element in "empty" slice and put the value of non-empty grid into that position. In exchange, the current non-empty grid's value is emptied. We simply swap the two grid's value.

Each directions have slightly different way to implement the logic, but basically they all followed the same pattern.

After that, we update "free" slice so that the next random placement of numbers would reflect our current board situation.

Finally, both steps have "valid" variable set to true on the combining and swapping phase. The variable is used to tell whether we need to call getNext function. If there is no combining or pushing numbers, then the user has not make a valid move and the turn isn't ended yet (by which no new number will be put on board). User must find another move so that they can either combine or push numbers. The check is done in the last lines of this function.

Printing the Output

Although we can technically play the game using the logic above, there's nothing to see on screen. We will code the functions needed to output the game into console now.

func printLine(str string, x int, y int) {
	for i := range str {
		termbox.SetCell(x+i, y, rune(str[i]), termbox.ColorDefault, termbox.ColorDefault)
	}
}

func drawLine(y int) {
	for x := 0; x < 29; x++ {
		termbox.SetCell(x, y, rune('='), termbox.ColorDefault, termbox.ColorDefault)
	}
}

func drawCell(x, y int) {
	num := board[x][y]
	str := strconv.Itoa(num)

	//space padding
	if num == 0 {
		str = "    "
	} else if num < 10 {
		str = "  " + str + " "
	} else if num < 100 {
		str = " " + str + " "
	} else if num < 1000 {
		str = " " + str
	}

	l := len(str)

	for i := 0; i < l; i++ {
		termbox.SetCell(x*7+2+i, y*4+2, rune(str[i]), termbox.ColorDefault, termbox.ColorDefault)
	}
}

func drawRow(y int) {
	//draw column divider
	for x := 0; x < 29; x += 7 {
		termbox.SetCell(x, y*4+1, rune('|'), termbox.ColorDefault, termbox.ColorDefault)
		termbox.SetCell(x, y*4+2, rune('|'), termbox.ColorDefault, termbox.ColorDefault)
		termbox.SetCell(x, y*4+3, rune('|'), termbox.ColorDefault, termbox.ColorDefault)
		termbox.SetCell(x, y*4+4, rune('|'), termbox.ColorDefault, termbox.ColorDefault)
	}

	//draw cell
	drawCell(0, y)
	drawCell(1, y)
	drawCell(2, y)
	drawCell(3, y)
}
	

The main component for our output is termbox.SetCell function. This function takes x and y coordinate of the console screen and put a character there. Although it is convenient to be able to specify exact coordinate, the limitation is this function can only put one character any time it is called. We can also specify the foreground and background color of the character with the next two arguments after the character, in respective ordering.

printLine is basically our way to solve the problem above. It takes a string and the x-y coordinate where the string would start at. Then it loops on each character of the string, putting it one by one on console. We will use this to print any texts on screen later on.

drawLine is used to draw a line border using "=" character. This is purely aesthetic choice to make our playing grid looks better.

drawCell is the function used to draw each grid of our 4x4 board. The way we do it is that we print a 4x3 area of coordinates in console screen filled with spaces. In the 2nd line of the area will be used to show the number, which will be padded with space depending on how many spaces the number takes over.

Finally, drawRow is used to draw a row of our 4x4 board. It mainly draws them by calling drawCell four times. The code before calling drawCells are used to draw the columns using "|" character, again for aesthetic purpose only.

All of the codes above will be called together by one main drawing function:

func drawBoard() {
	termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
	drawLine(0)
	drawRow(0) //row 1
	drawLine(4)
	drawRow(1) //row 2
	drawLine(8)
	drawRow(2) //row 3
	drawLine(12)
	drawRow(3) //row 4
	drawLine(16)
	//print score
	printLine("Score: "+thousandSeparator(score), 0, 18)
	//print instruction
	printLine("Controls:", 0, 20)
	printLine("-Arrow Keys to combine numbers in respective directions", 0, 21)
	printLine("-Escape Key to exit game", 0, 22)
	termbox.Flush()
}
	

As you can see in above code, we simply call the functions based on what we want to show. We used printLine to show texts, like the score and instruction for player. At first, we use termbox.Clear to create an empty area in console for our program to print upon. Then we call all functions, basically just call to termbox.SetCell, to populate the screen. We can't actually see the output yet because it is only saved to the output stack and not the console itself. To show it to console, we finally used termbox.Flush.

While we're at it, let's create our win and lose screen as well. It is basically the same with drawBoard, just with different content.

func drawWinScreen() {
	termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
	printLine("CONGRATULATONS!!!", 0, 0)
	printLine("You have won the Game!", 0, 1)
	printLine("Your score: "+thousandSeparator(score), 0, 3)
	printLine("Press ESC to end the game...", 0, 8)
	termbox.Flush()
}

func drawLoseScreen() {
	termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
	printLine("YOU HAVE FAILED...", 0, 0)
	printLine("Give it another try! You can do it!", 0, 1)
	printLine("Your score: "+thousandSeparator(score), 0, 3)
	printLine("Press ESC to end the game...", 0, 8)
	termbox.Flush()
}
	

The result will look somehow like this. Feel free to change it to your own design.

Tying It All Together

Now that we have all the elements for the game, it is high time to put them all in one place so we can actually use them. Here comes the heart of any Go programs: the main function.

func main() {
	err := termbox.Init()
	if err != nil {
		panic(err)
	}
	defer termbox.Close()

	initBoard()
	drawBoard()

	loop:
	for {
		switch ev := termbox.PollEvent(); ev.Type {
		case termbox.EventKey:
			switch ev.Key {
			case termbox.KeyArrowUp:
				if status == NEUTRAL {
					changeBoard(UP)
					drawBoard()
				}
			case termbox.KeyArrowDown:
				if status == NEUTRAL {
					changeBoard(DOWN)
					drawBoard()
				}
			case termbox.KeyArrowLeft:
				if status == NEUTRAL {
					changeBoard(LEFT)
					drawBoard()
				}
			case termbox.KeyArrowRight:
				if status == NEUTRAL {
					changeBoard(RIGHT)
					drawBoard()
				}
			case termbox.KeyEsc:
				break loop
			}

			status = checkStatus()

			if status == WIN {
				drawWinScreen()
			} else if status == LOSE {
				drawLoseScreen()
			}
		case termbox.EventResize:
			switch status {
			case NEUTRAL:
				drawBoard()
			case WIN:
				drawWinScreen()
			case LOSE:
				drawLoseScreen()
			}
		}
	}
}
	

The first five lines are mandatory to use termbox. We initialize termbox and then uses defer to close it when our program ends. If we don't close termbox, our console would not return to its normal state. That's why this is important.

Next step is to use initBoard to create our board, and then use drawBoard to show it to the screen. Now we're ready to accept user input. We state a label named "loop". We will use this label to break out from the game loop. Basically, every game is simply a program looping over and over while waiting for user input. In this case, we use for loop with no condition, making this an infinite loop. Our "loop" label is used later when we want to break out from this loop.

Within the game loop we use switch on termbox.PollEvent. This is the list of events happening to our program. It could be writing event, output event, or input event from keyboard, mouse, or other input sources. In this case we put the event to variable named "ev" and check the Type. If it is a keyboard press (termbox.EventKey), we simply use switch to compare it with the four constants of keyboard arrow keys provided by termbox and call changeBoard with appropriate direction constant. Then we draw the board again to show changes made because of our move. We also have a case for Escape key (termbox.KeyEsc) which will call break. Using the label "loop" after break means we will break out from repetition stated after "loop" label. In other words, the game loop. This is how the label is used to stop the game.

Regardless of what key is pressed, we will get the status of the game with checkStatus and put it in our global "status" variable (remember this little variable we created in the beginning?). Then we check whether the status is WIN or LOSE. Either case will make us display the appropriate win or lose screen and practically stopped the game from waiting for another move because each move in the code above will only work if the status is NEUTRAL.

Finally, in the PollEvent switch case we also have termbox.EventResize which will re-draw our screen if the window is resized.

Afterwords

In this tutorial, we have see how Go can be used to emulate modern game concept even in console form. We recommend to visit Go documentation for deeper understanding of Go. Hopefully this tutorial will grow more interest in Go language and could serve as a base for further creations.
ċ
go2048.zip
(678k)
Arden Sagiterry Setiawan,
Aug 25, 2014, 12:40 AM