Let’s recap the concepts related to game programming that we have covered so far:
These are already sufficient to create some fairly simple games. For more complex games, there is still more to learn.
Configuration
When designing a game, you are going to make a lot of arbitrary decisions. The game window will be 700×500 pixels large… or perhaps 900×700 would be better? The game will consist of 10 levels… or maybe 12 levels… plus one or two secret levels? The player can choose to play on “Easy” or “Hard” difficulty… or should it rather be “Easy”, “Medium”, and “Hard”? What is the title of the game?
It is considered a bad practice in programming to leave “magic values” interspersed in the code. For example, don’t write this:
for (int i = 0; i < 12; i++) {
loadLevel(i);
}
Instead, write this:
for (int i = 0; i < NUMBER_OF_LEVELS; i++) {
loadLevel(i);
}
Here, “magic value” refers to a number or text (such as “12” in this example), which just happens in the middle of the code, without an explanation. The fact that in this case you could successfully deduce its meaning from the surrounding code does not solve a greater problem, which is the following:
Suppose that you later change your mind and decide to have 13 levels instead. Is it enough to replace this one occassion of “12” with “13”? Or do you need to check the entire source code and replace some more 12s with 13s?
Before you open a “Find / Replace” dialog, consider the possibility that some other instances of “12” may actually refer to something else. Like, maybe each level consists of 16×12 blocks, or maybe the player gets 12 points for successfully doing something.
If instead you define constants such as “number of levels” (and similarly, “level width”, “level height”, and “points for collecting a coin”), you just need to change the value of the constant, and the correct number will be used in all places. Conveniently, the constants are usually declared at the top of the source file, making it easier to find them.
Configuration pushes this idea a little further. If we are not supposed to have the “magic values” in the middle of the code, why have them in the code at all? It would be even easier to find and change them if they resided in a separate text file. We would not even need to recompile the source code after changing the value.
We might keep multiple configurations, and try one or the other with the same code. The configurations would be easy to modify even for people who are not Java programmers; the players could mod their game. We could translate the game to a foreign language by extracting all texts to a configuration file, and then simply distributing the game with different versions of the language file. Once you start thinking about the possibilities you could unlock by making this relatively simple change, it is amazing!
On the other hand, if loading the configuration is a lot of work, maybe it’s ultimately not worth doing. Today, I will try to show you how simple it can be, if you can resist the temptation to overengineer things. (Think of it as a prototype that works for now, and you are free to overengineer it later if you wish so… but you will probably decide that it is not worth your time.) For about a dozen values, we do not need XML, JSON, YAML, or any other complicated format. Simple key-value pairs will suffice:
number of levels = 12
level width = 16
level height = 12
And we already have a standard Java class that can read such format; it is called Properties
. (It has too many methods and we only need a few of them, so we will wrap it in our own class that only exposes the two or three methods we really need.)
But what if we need to specify structured data, such as lists or maps? We could still write them like this:
# The size of the level, in blocks
level.width = 16
level.height = 12
# How many enemies you have to defeat at each difficulty level
difficulty.0.name = Easy
difficulty.0.enemies = 10
difficulty.1.name = Medium
difficulty.1.enemies = 30
difficulty.2.name = Hard
difficulty.2.enemies = 50
On one hand, these are all just key-value pairs. The Properties
class treats the dot like an ordinary character. (The lines starting with “#” are treated as comments, i.e. ignored. But they can contain instructions for the people who will mod the game.)
On the other hand, by implementing a method or two that support the dot syntax (such as: “give me all keywords following the ‘difficulty’ = [‘0’, ‘1’, ‘2’]”) we can traverse the structured data easily.
The levels of the game also shouldn’t be a part of the Java code. They probably cannot be conveniently reduced to a simple line of text, so we can put them in a separate file (just like we already do with the images and sounds), and only put the name of the file in the configuration.
High Score
Some games award you points for doing various activities (defeating enemies, collecting treasures), so even if you have already completed a level, you can still try completing it again with more points. For logical games, there are also some possible values to measure, such as how much time did it take you to complete the puzzle, or perhaps how many moves you made - though perhaps in this case “high” score is a misnomer, because lower values are the better ones.
The example used for this article is a solitaire game, where the player gradually eliminates pieces from the game board. The more pieces you eliminate, the better. (If you succeed to eliminate most of them, it is easier to count how many pieces have remained. So, the fewer pieces remained, the better.) There will always be at least one remaining piece - because you remove a piece by jumping over it by another piece, so when the last piece remains, there is no way to remove it - and I have no idea whether the outcome of only having one piece can actually be achieved on each game board.
Never mind; no matter what is the theoretical minimum, it is still true that fewer remaining pieces = better outcome. So the number of remaining pieces will be our “high score” for given game board. We will save these values, and their respective game boards, in a text file. This can also be a Properties
file, where the key is the game board, and the value is the smallest number of remaining pieces ever achieved.
From user perspective, the difference between saving/loading a game state and saving/loading high scores is that the latter happens automatically. The application decides when to load the file (when the game starts) and when to save it (e.g. when a new “high score” is made). The application chooses the position of the file on disk (e.g. user’s home directory).
User’s home directory depends on the operating system, and we can obtain the value like this:
String homePath = System.getProperty("user.home");
String filePath = homePath + File.separator + ".my-game-hiscore.txt";
Use the name of your application, so that the user knows what the file contains, and to reduce the risk of accidentally overwriting something else (a file saved by a different application, or by the user manually). If you need to save more than one file, create a subdirectory with the name of your application, and put all your files there.
The game code
The source code is available at gitlab.com/kittenlord/demo003.
Classes Position and Direction allow us to conveniently express things like “what is two steps away in this direction?” or “what is the direction between points A and B?”.
Class Resources supports loading images and text files, as usual. In this game, it also supports loading and saving the “high score” file.
Class Configuration represents the information written in “config.properties” file. Methods “getString” and “getInteger” return the value corresponding to given key.
If the method “getString” has more than one argument, the first argument is a template, and the remaining arguments are replacements for symbols like “%s” in the template. For example, getString("level.%s.name", "01")
returns the same value as getString("level.01.name")
. In other words, this is just a shortcut for using “String.format”.
The previous two methods are used in situations where we know which keys exist in the configuration file. The following one is used to discover the keys. Suppose that we know, in general, that the level names are stored in keys like “level.01.name” and “level.02.name”, but we don’t know (1) how many levels there are, and (2) whether it is exactly “level.01.name” or maybe “level.001.name” or even “level.LVL01.name”. In such situation, the method getKeys("level")
returns the possible values between the dots, following the provided argument.
Putting this together, we could print the names of all levels like this:
for (String levelCode : config.getKeys("level")) {
System.out.println(config.getString("level.%s.name", levelCode));
}
Now we are getting to the game-specific code. Class GameState contains the shape of the current game board, the positions of pieces, and which piece is selected.
The game board is stored in memory as Map<Position, FieldType>
, where the keys determine the shape of the board (if you want a non-rectangular board, only use the Position
s you want to have), and the values determine what is at the given position (an enumeration, currently with values PIECE
and EMPTY)
.
There are methods that encode the board state as text, which is useful for two reasons. First, we can load the initial board state from a text file in resources. It means that you can conveniently design new levels using a plain-text editor (like this). That is quite convenient, because programming our own level editor would be a lot of extra work.
Second, because we need the level as a key in the “high score” file, and — okay, I admit that I am overthinking this — if we use the initial game board itself as a key (instead of using the level name as a key), it means that if you modify the level later, the stored “high score” for this level automatically becomes invalid. (Technically, it would still be stored as a “high score” for a no-longer-existing level, but it would not be displayed or otherwise used in the application. Unless, perhaps, you later revert the changes.)
When loading the board state, if there are any completely empty rows or columns at the beginning or at the end, they are automatically removed. This is mostly so that you don’t have to worry while editing the levels, whether to press Enter after the last line or not — either way is okay; the results are exactly the same.
Converting the board state to a string is the opposite of loading it, but we also have a method toOneLineString
which connects all the lines into one, making it more convenient to use in the “high score” files.
Then there are methods for playing the game: canMove
checks whether moving a piece from one position to another is a valid move; canMoveAnywhere
scans the entire game board to find out whether there are any valid moves remaining (if there are not, it is time to display the “Game Over” message, and optionally save the new score). Method “clickAt” makes whatever changes are supposed to happen when the player clicks some field (selecting a piece, or moving it, or maybe nothing at all).
Finally, class Game creates the game dialogs. Because different levels can have different sizes, the size of the application main window is specified separately in the configuration file (to prevent resizing the window when you choose a different level). The actual game board is displayed centered in this window, with some nice picture in the background.
The “high score” file is saved when you close the application window, assuming there were any changes.
When you repaint some part of a canvas multiple times (for example, first you paint a background image over the entire canvas, then you paint the board pieces over the background image), it sometimes causes graphical distortions (the background image flashing briefly over the board pieces). This happens because the operating system has refreshed the canvas during your painting (after the background image was painted, but before the board pieces were painted). One possible way to prevent this is to create an in-memory image, paint everything to the image instead, and only at the end paint the completed image to the canvas.
You create the in-memory image like this:
BufferedImage buffer = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
And change the canvas painting code like this:
@Override public void paint(Graphics g) {
Graphics g2 = buffer.getGraphics();
... // just paint everything to g2 instead of g
g.drawImage(buffer, 0, 0, null);
}
The canvas code is also responsible for converting the positions on the game board into pixel coordinates on the screen, and vice versa. This is a simple linear transformation.
Suppose the dimensions of the canvas are “canvasWidth” and “canvasHeight”, the dimensions of a single game board piece are “pieceWidth” and “pieceHeight”, and the board contains “board.getWidth()” pieces horizontally and “board.getHeight()” pieces vertically.
Centering the board means calculating the total board size (number of pieces multiplied by piece size), subtracting that from the canvas size (the total empty space on both sides of the board), and dividing the result by two (the empty space on each side is the half of the total empty space). The result is the empty space on the side of the board, which means it is also a coordinate of the board on the canvas.
We can calculate these numbers when a new board is loaded, like this:
boardLeft = (canvasWidth - board.getWidth() * pieceWidth) / 2;
boardTop = (canvasHeight - board.getHeight() * pieceHeight) / 2;
Conversion of a position “posX, posY” into pixels follows the formula:
int pixelX = boardLeft + posX * pieceWidth;
int pixelY = boardTop + posY * pieceHeight;
Conversion of pixel coordinates “pixelX, pixelY” into a position follows the formula:
if ((pixelX < boardLeft) || (pixelY < boardTop)) {
return; // the pixel is outside the board
}
int posX = (pixelX - boardLeft) / pieceWidth;
int posY = (pixelY - boardTop) / pieceHeight;
if ((posX >= board.getWidth()) || (posY >= board.getHeight())) {
return; // the position is outside the board
}
(In case of a non-rectangular game board, the second check is not strictly necessary, because we would check whether the board contains the given position anyway.)
What next
This game has some potential to experiment with. The easiest thing would be to create your own level.
To achieve that, you need to create a text file describing the initial state of the board. Just look at how the existing levels are defined, and do the same thing (“x” = a piece; “o” = an empty place, you need at least one; “.” = space outside a non-rectangular board). Put the text file into the same resource folder as the existing levels.
Do not forget to also add the level metadata into “config.properties”, otherwise the game would not find it. Key "level.whatever.name" contains how you want to call the level (the text of the menu item that starts the level). Key "level.whatever.data" contains the name and extension of the text file; make sure you did not make a typo.
Now start the game, choose your new level in the menu, and play it until no more moves are left (i.e. until you get the “Game Over” message). When you quit the game, the “high score” file is saved. Try to find it on your disk, to see how the score for your new level was saved.
For Windows users: the file is located in your home directory, which is not the same as your “My Documents” directory; it is usually one level higher. Depending on your version of Windows, it will be something like “C:\Users\YourName”.
For Linux and Mac users: the file may be hidden, because its name starts with a dot. Try something like “ls -all ~/
” in the command line.
(If your new level is too large to fit in the game window, you also need to increase the “window.width” and “window.height” in configuration. Then you should also create a new background image with given dimensions; the file is in the game resources and its name is stored in configuration file under “image.background”.)
*
A more ambitious change would be to modify the game rules. Like, maybe a piece should be allowed to jump over more than one piece, as long as they are all in the same line. The entire game logic is in class GameState
, specifically the methods “canMove” (which says whether a move is possible) and “clickAt” (which makes the move).
You could introduce game pieces of different kinds, by adding new values to the FieldType
enumeration. Different kinds of pieces could follow different rules, for example a “normal” piece only jumps across one other piece (normal or special), but a “special” piece can jump across multiple pieces (normal or special). Or there could be a “hole” in the board, which cannot be selected, but another piece can jump into it (which would remove both the hole and the piece that jumped into it).
You also need to update methods “fromStringList” and “toStringList” to load and save the new game pieces. Also, make images for the new pieces.
Notice that the current game logic assumes that after a certain number of moves, no more moves are possible, and that is when the game ends (and the score is calculated). Currently this outcome is guaranteed by the fact that each move reduces the total number of pieces on the game board. You might accidentally break this assumption, e.g. by adding a rule that says “the piece can also move to the next empty field, without jumping”, in which case the game would become infinite (because you can always move the last remaining piece).
*
For more complex changes, it may be easier to create a new game from scratch (perhaps reusing Direction, Position, Resources, and Configuration from this game), which of course you are encouraged to do!