© Seth Kenlon 2019
Seth KenlonDeveloping Games on the Raspberry Pihttps://doi.org/10.1007/978-1-4842-4170-7_10

10. Save Files and Game States

Seth Kenlon1 
(1)
Wellington, New Zealand
 

The Battlejack game is fully functional, but there are still convenience functions to add, including saving and loading game progress, and switching between fullscreen and windowed mode.

Realistically, Battlejack doesn’t exactly demand a fullscreen mode, since it’s a relatively simple game. But for more complex games with a complex story, you might want to encourage immersion, and one way to help the player focus on the game and only the game is to give them the option for the game to take over their entire screen.

The problem with switching between fullscreen and windowed mode is that the graphic sizes must be recalculated. You may even see, in some games, that changing the resolution of the game requires the game to be relaunched before it can redraw. LÖVE can change dimension dynamically, as long as your code adjusts for the change.

Fullscreen

In its current state, your Battlejack code requires some changes to account for a sudden change in game window size. If you’re not a fan of math, many of the changes will seem a little mysterious, but the most important concept is the use of relative measurements. You must base the size of game elements on the width and height of the user’s current window size, but in order to do that you must ask the user’s system what that size is. In practice, this often means that you have to adjust the location of elements to account for a potential change in empty (or “negative”) space.

The first file that needs an update is the place where fullscreen mode is toggled on and off: the menu screen. There are a few LÖVE functions that deal with window sizes.
love.window.getFullscreen()
Whether or not the current window is in fullscreen mode. This function returns a Boolean value: true or false.
love.window.setFullscreen()
Activates fullscreen mode.
love.window.getMode()
Returns the mode of the window.
love.window.setMode()
Sets the window dimensions.
love.window.updateMode()

Forces the window to update its mode settings.

With these functions, you can always determine whether your game screen is fullscreen or windowed, and extract the dimensions of the space you have available.

The fullscreen toggle selection in menu.lua is option 3 (it’s the third selection in the menu entries array). Currently, there’s a placeholder there: it returns true, which in this context is Lua shorthand for not an error.

Erase the placeholder and fill in some useful logic to determine whether the window is currently in fullscreen mode. If it is not in fullscreen, place it in fullscreen mode and get the dimensions of the screen so that the variables WIDE and HIGH have accurate numbers in them for use by calculations in the game code. If the window is currently in fullscreen mode, set the width and height back to the default size. In either case, update the mode to make sure that LÖVE forces a redraw of the entire window.
elseif selection == 3 then
   if not love.window.getFullscreen() then
      love.window.setFullscreen(true, "desktop")
      WIDE,HIGH = love.window.getMode()
   else
      WIDE,HIGH = 960,720
      love.window.setMode(WIDE,HIGH)
   end
   love.window.updateMode(WIDE,HIGH,{resizable=true,vsync=false,minwidth=WIDE,minheight=HIGH})
   fsupdated = 1

At the end of this code, a new variable called fsupdated is created and set to 1. This variable serves as a flag signaling that the window mode has been changed, which in turn lets you write code in game.lua that only runs when a window mode change is made.

For the game to resize, several adjustments are required. Some game elements in Battlejack have been purposefully misconfigured in earlier chapters so that you can see the difference between a reasonable first attempt and, ultimately, the correct code. The important thing to understand is that when scaling a game, you must think about many different factors, and these factors may be unique to your game, depending on the assets you use. The examples in this section are specific to Battlejack, but the principles of using variables for calculations, detecting changes in screen settings, and knowing what can and cannot scale, are broadly applicable no matter what your game is like.

Open game.lua for editing. The various states of the game are defined by the variable STATE. Any time a user enters game mode, it is by triggering the game.activate function , whether by selecting New game or pressing Esc. That means that any time a user is in the menu screen, the game.activate function is the entry point to the game, and the ideal place to detect whether the screen size has changed.

Change game.activate() function to match this code.
function game.activate()
   -- switch to game screen
   STATE = game
   ground = love.graphics.newQuad(0,0,WIDE,HIGH,150,150)
   font = love.graphics.setNewFont("font/Arkham_reg.TTF",72)
   if fsupdated == 1 then
      scale = game.scaler(WIDE,790)
      local arr = {hand,horde,back,grab}
      for i,tbl in ipairs(arr) do
     for n,obj in pairs(tbl) do
        obj.wide = obj.img:getWidth()*scale
        obj.high = obj.img:getHeight()*scale
     end
      end
      fsupdated = 0
   end
end

If the fsupdated variable is 1, this function now your game.scaler function to recalculate a new scale for each card relative to the size of the screen.

Additionally, the function cycles through each of the hand, horde, back, and grab decks and applies the new size to the cards in play.

Finally, it sets the fsupdated to 0 so that no further size updates are triggered until the user changes the screen settings again.

Launch the game and change the screen setting now. Take note of what scales properly and what does not. Spend some time thinking about the problems you see, and try to predict what function contains the problem code.

Usability

One point of confusion you might detect after testing is that there’s no explicit way to return to the game from the menu screen. On one hand, it’s reasonable to expect that since the user had to press Esc to get to the menu screen in the first place, the user can probably guess that pressing Esc again returns to the game. On the other hand, making the user think too hard about an interface can be frustrating, so there’s no reason to leave it up to chance.

Add a menu option to return to a game already in progress. Phrases like “Exit menu” and “Resume game” can be taken many different ways: exiting the menu could also mean exiting the application, and resuming a game could mean loading a previously saved game. A good interface uses the clearest possible language, which in this case is simply “Return to game”.

First, add the new option to the menu entry array in menu.lua.
local entries = { "New game", "Load saved",
      "Window mode", "Save", "Return to game", "Quit" }
Change the menu maximum value, which is used by the wrap function to move the selection marker from the bottom of the menu back to the top, to match the number of menu entries. Previously, the menmax value was hard coded to 5, but by now you are familiar with some new array shortcuts, so it makes sense to make the menmax a dynamic value that sets itself to whatever number of items in the entry array.
local menmax = #entries
In the menu.draw function, change the for loop to repeat itself from 1 to whatever value is contained in the menmax variable. The first and last line in this code block are for context.
love.graphics.setBackgroundColor(0.1,0.1,0.1) --for context
for i=1,menmax do
   if i == selection then --for context
The addition of a new entry has pushed the Quit option back to item 6, so adjust the menu code.
elseif selection == 5 then
    game.activate()
elseif selection == 6 then
    love.event.quit()
end

Scaling Adjustments

Generally, measurements that need to change dynamically are more predictable when they are based on a single point of authority. All the scaling problems you are witnessing in your test are bugs in the game.draw function, where there is heavy reliance on card.x and card.y values. Since there are several arrays defining cards, the values stored in various card values are unpredictable. There is also some dependence on the pad value, which defines a margin around the edges of the screen, but it changes depending on the screen size. And finally, while the slot variable provides the width of the cards, there’s no value at all for the height of cards in relation to the screen size.

In summary, the layout of a dynamically resizable game must be based on the size of its parent window.

To fix your scaling bugs, first determine the aspect ratio (width divided by height) of your cards. This value will help determine the relative height of cards regardless of size. Create a global variable for this value at the top of game.lua.
ratio = 1.37
Next, change the values for the player’s draw deck (the card back). The first and last lines are for context.
card = back[1] --for context
love.graphics.draw(glow,pad,HIGH-slot-(slot*ratio),0,scale,scale,0,0)
love.graphics.draw(card.img,pad,HIGH-(slot*ratio)-pad,0,scale,scale,0,0)
love.graphics.setColor(0,0,0) --for context

Notice that the new method of placing the glow and the deck relies exclusively on values derived from the screen size, and not at all on card-specific variables. As usual in programming, there are actually several different ways to arrive at the same solution, but this is the most efficient; an alternative is to leave the code unchanged, and use screen size calculations to update the important card variables, but that requires updating values in several places, whereas the screen size calculations are made once and then can be used many times. Any time you have the opportunity to require the computer to do less work, you are optimizing your code, and that’s always a good thing.

The same principles apply to the AI’s deck.
card = back[2] --for context
love.graphics.draw(shadow,WIDE-(slot)-pad,slot+(slot/4),0,scale,scale,0,0)
love.graphics.draw(card.img,WIDE-pad,slot+pad+slot/4,0,-1*scale,-1*scale,0,0)
The running score for the player’s hand also needs an update.
love.graphics.printf(tostring(handval),(slot)-slot/2,HIGH-(slot*ratio)-pad-pad,slot/2,'center')
And for the running score for the AI.
love.graphics.printf(tostring(hordeval),WIDE-(slot/2)-pad*2,(slot*ratio)+pad,slot/2,'center')
The screen text announcing the winner and loser requires the same adjustment, because the screen size ratio is not necessarily the same between its windowed mode and its fullscreen mode (depending on the resolution the user’s physical monitor is). Still in the game.draw function, update the printf statements to match this code block.
if progress < 2 then
   love.graphics.printf("Level " .. level,0,HIGH-(slot*ratio)-(pad*ratio)-72,WIDE,'center')
end
if winner == "hand" then
   lastwon = 1
   love.graphics.printf("You have won!",0,(slot*ratio)+(pad*ratio)-72,WIDE,'center')
elseif winner == "horde" then
   lastwon = 0
   love.graphics.printf("You have lost.",0,(slot*ratio)+(pad*ratio)-72,WIDE,'center')
elseif winner == "tie" then
   lastwon = 0
   love.graphics.printf("Tied game.",0,(slot*ratio)+(pad*ratio)-72,WIDE,'center')
end

The 72 in each line is the font size of the onscreen text. By now, you’re hopefully suspicious of any hard coded value, so you’re probably wondering whether even this value ought to become relative. In fact, you could make the font size more dynamic by either using some calculation to determine the optimal size depending on screen size, or you could create an array of font sizes for specific ranges of screen sizes. As screen sizes vary wildly in sizes, from mobile phones to 4k monitors, this is a worthwhile exercise, but one left for you to manage on your own.

All of these changes have affected the grab code. When a player selects a card, it’s added to the grab array, but it’s also moved up the screen and given a particle effect, very precisely placed just under its top border.

In this case, changing the values of one or two or even three grabbed cards is manageable, so for each card in the grab array, adjust the Y position and then use that value to calculate the position of the particle effect. Since all values are relative to the screen size, the particle effect is anchored to the card size even when the card size has changed to fit a new screen size. Find and update this code block in your game.draw function.
for i,obj in pairs(grab) do --for context
   local count = 1          --for context
   while count < obj.wide/mana:getWidth() do --for context
      obj.y=HIGH-(slot*ratio)-pad*2
      love.graphics.draw(parti,obj.x+(mana:getWidth()*count+1),obj.y+32/2)
      count = count+1 --for context
   end                 --for context
end -- for context

Notice that the particle effect line uses 32/2 instead of the old pad/3 calculation to position the particle image below the card’s top border. This is dependent on the particle image, so if you have changed or plan on changing the particle seed image, you must adjust the value 32 to match the height of your custom image. To make this kind of change even easier, you could create a variable at the top of the file to define the height of the particle image, and then use that variable in this code. That way, all the values you need to change are easily found at the very top of your file, saving you from having to search through your code for all the hard coded values requiring updates. Again, this exercise is left to you to do on your own time.

When the player’s hand is drawn, you must change the code so that items in the grab table are moved up to their new Y position, and items in the hand table stay in position.
   for i,obj in pairs(hand) do --for context
      if game.isselected(obj,grab) then
         love.graphics.draw(obj.img,obj.x,obj.y,0,scale,scale,0,0)
      else
     obj.x = pad+(slot*i)
     obj.y = HIGH-(slot*ratio)-pad
     love.graphics.draw(obj.img,obj.x,obj.y,obj.r,scale,scale,0,0)
      end
   end --for context

Try playing the game again to experience dynamic switching of screen sizes. Make adjustments and changes as required, and remember to commit your changes to Git once satisfied.

Save States

Save states for games are a nice convenience for users, and generally expected in modern gaming. Luckily, there’s a wide range in what users accept for save states. Some games save every last detail of a game so that when you resume a saved game, it’s as if you had only paused the game. Others save less information, placing you back at a waypoint or checkpoint. Still others only save your level and nothing else.

Considering that Battlejack falls within the puzzle or card game genre, saving just the player’s level would probably be acceptable by most users. However, Lua’s heavy use of tables makes saving and restoring complex data easy, so Battlejack can have full save state support.

There are two bundles of information that need to be saved: there’s the player information and the game state. Player information is anything that remains true regardless of a game round, such as whether or not the game is in fullscreen mode, and what level the player has reached. Game information is anything specific to the current round, such as the contents of the draw decks and the player’s hands.

You will use two different methods of saving this information, and the information is saved to two separate locations on the user’s drive. Player information is generally referred to as the user configuration and the game information is game data or just data.

Create a new file called saver.lua in your project directory. Open it and add a package.path and a requirement of the inifile module . Also, establish a table to hold the functions you will create for it.
package.path = package.path .. ';local/share/lua/5.3/?.lua'
inifile = require('inifile')
saver = {}

One of the most common errors when saving and loading data in an application’s code are missing files or directories. For instance, if you write Lua code to save a the file bar.conf in a directory called foo, if Lua can’t find the foo directory, then Lua crashes.

Lua has no way of knowing whether a file or a directory exists, so you must write a function for this. This is a low level operation that is usually the domain of the operating system, and as such Lua’s os function has some tools that can help solve this puzzle. Lua functions are documented on the Lua website ( www.lua.org/manual ) and also in the book Programming in Lua by Roberto Ierusalimschy (Lua.​org, 2016), the official Lua guide from the language creators.

While there is no function to determine whether a file or directory exists, the os.rename function requires that a file or directory exists in order to successfully rename it. It’s a little bit of a hack, but by invoking os.rename on a file path, you can parse its output to determine whether it was able to find a file or not.

For example, here’s what a successful os.rename action looks like.
$ touch foo
$ lua
> os.rename("foo","bar")
true
An unsuccessful os.rename action:
> os.rename("foo","baz")
nil No such file or directory   2
And finally, here is an edge case in which os.rename finds a file but is unable to rename it.
> os.rename("foo","bar")
nil Permission denied   13
By assigning each part of the output to a variable, you can determine whether or not a file path exists. Add this code to your saver.lua .
function saver.exists(path)
   local success, err, num = os.rename(path, path)
   if not success and num == 13 then
      return true
   end
   --returns true or false
   return success
end

User Configuration

To save user configuration, you must create a global table in game.lua called conf, and establish a default location for where the configuration file is to be saved. Add the last line from this code block.
grab  = {} --for context
up    = {} --for context
earn  = {} --for context
conf  = {} --user config

This default location is not arbitrary. In the computer world, everyone benefits from standards. Standards are conventions that programmers mutually agree to follow in order to ensure compatibility. Most modern low-level computing standards are defined by the open source POSIX specification, while user-level specifications for Linux are defined by freedesktop.​org. Operating systems that are not open source often follow a combination of open standards and their own standards. You have the option of following one or the other, but since the open standards are available to all, this book follows those.

Freedesktop.​org defines two hidden directories in a user’s home for configuration and application data. The .config directory contains configuration data and .local/share contains application-specific data files.

Enter these new variables near the top of the game.lua file.
home = os.getenv('HOME')       --for context
d    = package.config:sub(1,1) --for context
confdir = home .. d .. '.config' .. d .. 'battlejack' .. d
datadir = home .. d .. '.local'  .. d .. 'share' .. d
Use these variables to build a table containing user configuration options, and write those options to the drive in a new userdata function in saver.lua.
function saver.userdata()
   conf.user = {}
   conf.user.level = tostring(level)
   conf.user.fullscreen,fstype = love.window.getFullscreen()
   -- does config directory exist?
   if not saver.exists(confdir) then
      os.execute("mkdir " .. confdir)
   end
   inifile.save( confdir .. d .. 'battlejack.ini', conf, "io" )
end

The if statement that invokes saver.exists is a subroutine that ensures the destination for the save file exists. If it does not exist, the os.execute function runs the mkdir command on the operating system to create the directory. The mkdir command works on Linux, MacOS, BSD, and Windows.

The last line of the code block uses the inifile library to save the conf table to a file. It uses the io Lua module to create the file.

Now modify menu.lua so that it uses your new saver library. Place the requirement at the top of the file.
require("saver")
When you originally created the menu, you used a placeholder for selection 4. Erase it and put in a call to the function you have just created.
elseif selection == 4 then
   saver.userdata()

Game Data

Saving the game data is theoretically a simple matter of taking all the tables that contain game information and dumping their contents into a file. That’s a lot of code to write, and also potentially prone to error if a table is very complex. It’s also a common task, however, so a user on the lua-users.​org website created a handy script to save and load tables. This is the sort of script you might usually find with Luarocks, but it just happens that this script has never been entered into the Luarocks repository, so it just exists on the Internet.

Download the script from lua-users.org/wiki/SaveTableToFile or from the code included with this book. However you obtain it, save it in your project directory, in the local/share/lua/5.3/ folder, as table_save.lua.

Caution

You must rename the file from its default table.save-1.0.lua to table_save.lua to avoid Lua from interpreting the file name as a table.

The functions from table_save.lua are going to be used by both saver.lua and game.lua, since the former needs to save table to files and the latter needs to load those files back into tables. To keep with the convention of keeping global variables all in one place, add table_save.lua as a requirement for game.lua (the functions will be available to saver.lua because the functions become part of the application’s global namespace).
require("card")
require("msg")
require("table_save")
In saver.lua, create a new function to process the game data, and make sure the appropriate directory structure exists, using the variable for your datadir you created in game.lua.
function saver.gamedata()
   if not saver.exists(datadir .. 'battlejack') then
      os.execute("mkdir " .. datadir .. 'battlejack')
   end
Next, add the code to save the card decks to files. The syntax of the save function provided by table_save.lua are given in comments at the top of the file.
table.save( table , filename )
on failure: returns an error msg

Of course, if you only provide a filename, Lua would do exactly as it is told and save the files into your current directory , so you must interpret filename broadly to mean the path to the file you want to create.

Add this to the saver.gamedata function.
   --current hand
   table.save(hand,datadir .. 'battlejack' .. d .. 'hand.tbl')
   --current horde
   table.save(horde,datadir .. 'battlejack' .. d .. 'horde.tbl')
   --current masterdecks
   table.save(deck,datadir .. 'battlejack' .. d .. 'deck.tbl')
   table.save(ai,datadir .. 'battlejack' .. d .. 'ai.tbl')
end

Launch the game and start a round. Once you have drawn one or two cards, go to the menu screen. Save the game and then quit.

In a terminal, view the user config file that has been created.
$ ls  ~/.config/battlejack
battlejack.ini
# cat ~/.config/battlejack/battlejack.ini
[user]
level=0
fullscreen=false
Confirm that the decks have also been created.
$ ls ~/.local/share/battlejack/
ai.tbl  deck.tbl  hand.tbl  horde.tbl

Loading a Save File

The inverse of saving a game is loading a game. This is a little more complex than the save process, because there is so much setup when starting a new game, which is “clobbered” by loading in existing data. Because loading a game replaces the game.new and game.setup functions when invoked, the loading process happens in the game.lua file.

First of all, you want to ensure that you’re facing a clean workspace when loading a saved game. Currently, the slate is cleaned by game.new, but if you move the cleanup code to its own function, then you can use it again in your loading code.

Take these lines from game.new and place them into a new function called game.cleanup .
function game.cleanup()
   collectgarbage()
   game.blast(deck)
   game.blast(ai)
   game.blast(hand)
   game.blast(horde)
   game.blast(back)
   game.blast(grab)
   winner   = nil
   progress = 0
   game.scaler(WIDE,790)
end
Call the game.cleanup function at the top of the game.new function .
function game.new()
   game.cleanup()
Create a new function called game.load for loading in user and game data. Call the game.cleanup function at the top, and load the user configuration in using the inifile library .
function game.load()
   game.cleanup()
   if saver.exists( confdir .. 'battlejack.ini' ) then
      local userconf = inifile.parse( confdir .. 'battlejack.ini', "io" )
      level = userconf['user']['level']
   else
      print("no user INI found")
   end

The next logical step would seem to be loading the game data, but first there’s another bit of code that usually only happens in game.new: generating a background.

Any time you can, you generally should reuse code. Move the following lines of code from the game.new function into a new game.background function.
function game.background()
   ground = love.graphics.newQuad(0,0,WIDE,HIGH,150,150)
   tile   = love.graphics.newImage('img' .. d .. 'tile.jpg')
   tile:setWrap('repeat','repeat')
end
Similarly, the draw decks (the card backs) are generated in the game.setup function. You can’t call game.setup because it clobbers several settings that game.load performs, such as building the decks, shuffling the decks, adding a Joker card, and so on. Move this code from game.setup into a new function called game.backs .
function game.backs()
   -- create GUI decks
   -- hand back
   card = Card.init("c","v","back",pad,HIGH-(slot*ratio)-pad) --HIGH-slot-(pad*2))
   back[#back+1] = card
   -- horde back
   card = Card.init("c","v","back",WIDE-(slot/2)-pad,slot-(pad))
   back[#back+1] = card
end
Call the function at the top of game.setup .
function game.setup()
   game.backs()
   deck = game.setsplit("card",1,deck,2) --for context
Call both of your new functions in your game.load routine :
   else --for context
      print("no user INI found") --context
   end --for context
   game.background()
   game.backs()
Loading tables back into Lua is explained in the comments at the top of the table_save.lua file.
table.load( filename or stringtable )
Loads a table that has been saved via the table.save function
on success: returns a previously saved table
on failure: returns as second argument an error msg
The tricky thing about Battlejack’s tables are that some contain card descriptions and others contain cards saved as tables. Luckily, you have a card building library that converts card descriptions to drawable cards, so reconstructing hand and horde is a trivial for loop. Enter this code at the bottom of your game.load function .
   game.background() --for context
   game.backs()      --for context
   --get deck states
   if saver.exists( datadir .. d .. 'battlejack' .. d .. 'hand.tbl' ) then
      tbl = table.load( datadir .. d .. 'battlejack' .. d .. 'hand.tbl' )
   end
   --build decks
   for i,obj in pairs(tbl) do
      card = Card.init(obj['color'],obj['value'],obj['face'],obj['x'],obj['y'])
      hand[#hand+1] = card
   end
   if saver.exists( datadir .. d .. 'battlejack' .. d .. 'horde.tbl' ) then
      tbl = table.load( datadir .. d .. 'battlejack' .. d .. 'horde.tbl' )
   end
   for i,obj in pairs(tbl) do
      card = Card.init(obj['color'],obj['value'],obj['face'],obj['x'],obj['y'])
      horde[#horde+1] = card
   end
      --get deck states
   if saver.exists( datadir .. d .. 'battlejack' .. d .. 'deck.tbl' ) then
      deck = table.load( datadir .. d .. 'battlejack' .. d .. 'deck.tbl' )
   end
   if saver.exists( datadir .. d .. 'battlejack' .. d .. 'ai.tbl' ) then
      ai = table.load( datadir .. d .. 'battlejack' .. d .. 'ai.tbl' )
   end
   game.activate()
end

Once the load process is finished, it calls game.activate to put the user back in the game.

Launch the game and load your saved game. Try this process a few times, and play through a few games. Take note of any errors or glitches you encounter.

Homework

The next chapter makes no further changes to the game code aside from adding sounds to the experience. That means the game is basically feature-complete, so any glitches or unoptimized code you find are permanent unless you change it.

Here are some examples, but don’t limit yourself to this list.
  • Only one save is supported. Program a method to permit different save slots.

  • If a user forgets to save their game before quitting, they lose their progress. Devise a system to avoid this.

  • The text of the message screen isn’t always positioned correctly for all screen sized. Change the code of msg.lua to fix this problem.

Remember to commit your changes to Git to preserve snapshots of your progress.