In Part 10 of this tutorial, we explored saving and loading from an external file, using the shelve module. While this is a good solution for many, shelve can be inconsistent in its behavior across platforms, operating systems, and Python versions.

A more consistent method would be to save and load the data to JSON files ourselves. It will take some extra work, because we can't just save Python objects to JSON, but the result is something that will work regardless of which operating system we're on, or which distribution of Python we're using (assuming your Python release has the json package; it's been part of the standard library for nearly 10 years, so you're probably fine).

So if we can't just save objects to JSON, then how do we approach it? We'll need some functions to convert our classes and objects into a JSON format, and vice versa. So why not give each class we need to serialize/deserialize a method for each? Specifically, we'll add a to_json and from_json to the classes, the former to save the data as a JSON object, and the later to create a new object from that JSON. All we have to do then for saving our game is call to_json on each object and save the results, and for loading, we can get the data from the file and pass it all through the from_json methods.

Let's work backwards through this chapter: I'll start by showing you the end result, the save_game and load_game functions (which are in a file called json_loaders.py, which is in the loader_functions directory). We'll then analyze those functions, determining what we need to make each one work, and then implement what we need.

import json

from entity import Entity

from game_messages import MessageLog

from game_states import GameStates

from map_objects.game_map import GameMap


def save_game(player, entities, game_map, message_log, game_state):
    data = {
        'player_index': entities.index(player),
        'entities': [entity.to_json() for entity in entities],
        'game_map': game_map.to_json(),
        'message_log': message_log.to_json(),
        'game_state': game_state.value
    }

    with open('save_game.json', 'w') as save_file:
        json.dump(data, save_file, indent=4)


def load_game():
    with open('save_game.json') as save_file:
        data = json.load(save_file)

    player_index = data['player_index']
    entities_json = data['entities']
    game_map_json = data['game_map']
    message_log_json = data['message_log']
    game_state_json = data['game_state']

    entities = [Entity.from_json(entity_json) for entity_json in entities_json]
    player = entities[player_index]
    game_map = GameMap.from_json(game_map_json)
    message_log = MessageLog.from_json(message_log_json)
    game_state = GameStates(game_state_json)

    return player, entities, game_map, message_log, game_state

Let's start by going over what we don't need to do: the player_index and game_state. player_index is just an integer, so we can save and load that directly from JSON, no extra code required. game_state is a bit more complicated, but not much. We can't save a Python Enum to JSON, but we can save the integer value that it stores. When we reload the game_state, we just recreate it based on the integer we get from game_state_json. Simple!

Now for what we actually need to do: The entities, game_map, and message_log all need to be serialized and deserialized. We'll start with the game_map. In order to do this, we'll need to modify both Tile and GameMap. We'll start with Tile.

class Tile:
    def __init__(self, blocked, block_sight=None):
        self.blocked = blocked

        if block_sight is None:
            block_sight = blocked

        self.block_sight = block_sight

        self.explored = False

    def to_json(self):
        json_data = {
            'blocked': self.blocked,
            'block_sight': self.block_sight,
            'explored': self.explored
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        blocked = json_data.get('blocked')
        block_sight = json_data.get('block_sight')
        explored = json_data.get('explored')

        tile = Tile(blocked, block_sight)
        tile.explored = explored

        return Tile

To break it down a little: The to_json method takes the tile's attributes and puts them into a dictionary that can be saved to JSON, and returns it. Each one of our classes that we need to serialize will do this in some way or another.

Then, we've got the from_json method. Notice that it's a static method; that's because while it's directly related to the Tile class, it doesn't need to reference a specific instance of it. You could just as easily have this function live somewhere outside the class.

The from_json method takes the same dictionary as returned by to_json and creates a new Tile object from it. In essence, these methods do the opposite of each other. One is to save the JSON data, the other is to load it and recreate the object.

Now let's move on to the GameMap. We'll also add a to_json and from_json pair of methods to it (in fact, we'll be doing that for all the classes we're saving).

...
class GameMap:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.tiles = self.initialize_tiles()
    ...
    
    def to_json(self):
        json_data = {
            'width': self.width,
            'height': self.height,
            'tiles': [[tile.to_json() for tile in tile_rows] for tile_rows in self.tiles]
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        width = json_data.get('width')
        height = json_data.get('height')
        tiles_json = json_data.get('tiles')

        game_map = GameMap(width, height)
        game_map.tiles = Tile.from_json(tiles_json)

        return game_map
...

Saving and loading the width and height are straightforward, because they're just integers. However, the tiles are a bit more complicated, because each tile is a class. But because we now have the to_json and from_json methods on the Tile class, the conversion is easy. We just have to call the to_json and from_json methods when we want to save and load the tiles, respectively.

*Note: Notice how we're setting the tiles attribute on the map after initialization. Perhaps a better way to do this would be allowing the GameMap constructor to accept the tiles. However, in the interest of keeping consistent with the base tutorial, I won't do that here, but feel free to do so in your own code if you prefer.

And that's all we need for the game map! If you've added your own custom attributes to the map, you'll want to include those in the to_json and from_json, but otherwise, we can now represent our maps in JSON, and create maps from it!

Now let's move on to the message log. Open up game_messages.py and add the to_json and from_json methods to both Message and MessageLog.

...
class Message:
    def __init__(self, text, color=(255, 255, 255)):
        self.text = text
        self.color = color

    def to_json(self):
        json_data = {
            'text': self.text,
            'color': self.color
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        text = json_data.get('text')
        color = json_data.get('color')

        if color:
            message = Message(text, color)
        else:
            message = Message(text)

        return message


class MessageLog:
    def __init__(self, x, width, height):
        self.messages = []
        self.x = x
        self.width = width
        self.height = height

    def add_message(self, message):
        new_msg_lines = textwrap.wrap(message.text, self.width)

        for line in new_msg_lines:
            if len(self.messages) == self.height:
                del self.messages[0]

            self.messages.append(Message(line, message.color))

    def to_json(self):
        json_data = {
            'x': self.x,
            'width': self.width,
            'height': self.height,
            'messages': [message.to_json() for message in self.messages]
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        x = json_data.get('x')
        width = json_data.get('width')
        height = json_data.get('height')
        messages_json = json_data.get('messages')

        message_log = MessageLog(x, width, height)

        for message_json in messages_json:
            message_log.add_message(Message.from_json(message_json))

        return message_log

The to and from methods for Message are very straightforward: just save the text (a string) and color (a tuple of integers) into a dictionary, and then create a message from that same dictionary. MessageLog is where things get more interesting. Let's break that down.