A simple puzzle game (part 1)
You know how if you have a box of little square things, and you rotate it, all the pieces thunk to the bottom and it’s kinda satisfying? Even more so if all the pieces are (say) dice, and they make a neat stack when they hit. That was my inspiration when I decided to make a puzzle game the other day. In this game, you have a square board partially full of colored pieces, and each turn you can rotate it ninety degrees either left or right, and all the pieces fall to the bottom. The pieces are four different colors, and there are four special pieces (one of each color) that are crushers. A crusher landing on top of a piece of its color will crush that piece and shift down one square.
What I’m going to do is talk about the design of the game and how I implemented the rules in this post, and then how I wrote the graphics and interface code in the next post. The post after that will have the final version of the game, with some explanation about how to organize code like this. But first, let’s write the game rules.
Making a board
Lua only has one data structure, but it’s a surprisingly versatile one: tables allow both numeric and object indices, even at the same time, so they work well for storing a bunch of data and its metadata. We’ll make a function where you pass in a size and a number of squares you want filled (and an optional random seed) and it returns a table with 0 .. size2-1 elements and a size
field:
function make_board(size) local board = { size = size } for n = 0, size * size - 1 do board[n] = 0 end return board end
We’re going to buck Lua convention here and index it from zero instead of 1. No big thing because we’re never going to use ipairs on this, and we know what size it is anyway. Now, we’re going to make a second function to put a bunch of blocks into the board:
function populate_board(board, filled, seed) local size = board.size if seed then math.randomseed(seed) end filled = filled or size * size * 3 / 4 local function rand() local c repeat c = math.random(size * size) - 1 until board[c] == 0 return c end if filled > 0 then for _,v in ipairs{'a','b','c','d'} do board[rand()] = v end for n = 1, filled-4 do board[rand()] = math.random(4) end return fall(board) end end
There are a couple things going on here. The function has a sub-function for finding a random empty square, and we call it a bunch of times to ensure the board is 3/4 full of stuff. The cells are 0 for empty, 1-4 for normal blocks, and ‘a’-‘d’ for crushers. We let the caller specify a random seed so that we can call this more than once and get the same thing out, for testing. At the end, we call fall
to make everything in the board fall to the bottom.
Functional style
The fall
function, and actually every other function in the game rules, will be written using something called functional style. This just means we’re going to follow a couple rules:
- The result of a function should depend solely on its parameters. Calling a function with the same parameters twice must return the same values.
- The result of a function must be a different, new object. A function cannot change the things passed to it in any way.
So what this means is that we’re going to have functions like fall
that take a board and mean “what’s the board you get when you take this board and let the pieces fall to the bottom”. In code, it will look like this:
function fall(board) local new_board = make_board(board.size) -- . . . Do stuff to populate new_board . . . return new_board end
We never change the actual values in a board after it’s been created. We might change the value of a variable by making it refer to a different board, but we will never change what values are in which cells in a particular board. So, with that in mind, here’s the fall
function:
function fall(board) local size = board.size local new_board = make_board(size) local function fall_column(col) local dest = size - 1 for y = size-1, 0, -1 do if board[y*size + col] ~= 0 then new_board[dest*size + col] = board[y*size + col] dest = dest - 1 end end end for x=0, size-1 do fall_column(x) end return new_board end
This function in particular is a little bit tricky; read it like this: for each column, move from bottom to top in that column, and if you see a cell that is not empty, add it above the lowest non-empty cell in that column in new_board
. There are a couple idioms that show up all over the place, used here:
- Numeric for with three arguments. The third argument is the step size and is set to -1 to make the loop go backward.
board[y * size + x]
. The board cells are numbered like reading a book, with the top left cell being 0, top row being 0-7, second row being 8-15, and so on. Each row’s left index is that row times the size (0, 8, 16, 24, etc) and to move right from there you add an x offset, so the fourth column in row three is row three’s left edge (2 * 8, since the first row is 0) added to the offset for the fourth column (3, since the first column is 0).y * size + x
, so 2 * 8 + 3, so 19.
All the game rules
I’m not going to quote all the functions, but I will list them all: we have fall
, quoted above; rotate
which rotates the board one turn clockwise; crush
which removes any block underneath a like-colored crusher; and board_tostring
which prints out a pretty representation of a board (and goes in each board’s metatable). We don’t need a rotate-counterclockwise function because we can just rotate clockwise three times, and we don’t need a function to move the crushers down because fall
already does that. All of these functions are written in the same functional style, and return a new board while leaving the original one untouched (except board_tostring
which obviously returns a string).
So why do this? It’s less efficient, because we have to make more boards, right? There’s the garbage collection cost, the cost of initializing boards, the cost of copying data that doesn’t change (in crush
), what’s the point? Well, like any coding style, what we get out of it is fewer bugs. Since there’s no shared state between functions it makes it easier to test in the console, and never changing a board after creation removes a whole class of bugs where we forget to update something. Not to mention, we can change how these functions work (like, say, by having a new kind of crusher, or having crushers crush more than one thing) without changing anything outside that function.
The game loop
Now that we have all the verbs we need, we can write an actual game, of sorts. This is the test version that just works in the console. We’ll have a function called game
that makes a board and enters a loop; the player will type either ‘l’ or ‘r’, then the board will rotate, fall, crush, and redisplay. We’re going to do this to validate that our game rules all work right before we try to put in graphics or animations or things:
function game() local board = populate_board(make_board(8)) local line = nil repeat print(board) line = io.read() local valid = false if line == 'r' then valid, board = true, rotate(board) elseif line == 'l' then valid, board = true, rotate(rotate(rotate(board))) elseif line == 'exit' then valid = true end if valid then board = fall(crush(fall(board))) else print "Didn't understand that. Type 'l', 'r', or 'exit'." end until line == 'exit' end
Running this, we get the following example game:
Lua 5.1.4 Copyright (C) 1994-2008 Lua.org, PUC-Rio > game() | a 0 0 0 0 0 0 0 | | b 3 0 0 0 3 0 0 | | 1 3 0 3 0 3 2 4 | | 3 2 2 2 0 d 3 2 | | 3 3 1 4 0 4 1 4 | | 4 1 3 4 2 4 4 2 | | c 4 1 4 4 1 3 3 | | 2 4 4 1 1 3 2 2 | r | 2 c 4 0 0 0 0 0 | | 4 4 1 3 3 0 0 0 | | 4 1 3 3 2 1 0 0 | | 1 4 4 1 2 3 0 0 | | 1 4 2 4 2 3 0 0 | | 3 1 4 4 d 3 b 0 | | 2 3 4 1 3 2 3 0 | | 2 3 2 4 2 4 3 a | l | 0 0 0 0 0 0 0 a | | 0 0 0 0 0 0 3 3 | | 0 0 1 3 3 b 2 4 | | 0 3 2 2 2 3 3 2 | | 0 3 3 1 4 d 1 4 | | 4 1 3 4 2 4 4 2 | | c 4 1 4 4 1 3 3 | | 2 4 4 1 1 3 2 2 | exit
So the game seems to work. You can try it out here. Next post, we’ll go about adding in graphics and animation using Löve!