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_utils 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. Open up map_utils.py, and add the following two functions to the GameMap class.

...
class GameMap(Map):
    def __init__(self, width, height):
        super().__init__(width, height)
        self.explored = [[False for y in range(height)] for x in range(width)]

    def to_json(self):
        walkable = []
        transparent = []

        for y in range(self.height):
            walkable_row = []
            transparent_row = []

            for x in range(self.width):
                if self.walkable[x, y]:
                    walkable_value = True
                else:
                    walkable_value = False

                if self.transparent[x, y]:
                    transparent_value = True
                else:
                    transparent_value = False

                walkable_row.append(walkable_value)
                transparent_row.append(transparent_value)

            walkable.append(walkable_row)
            transparent.append(transparent_row)

        json_data = {
            'width': self.width,
            'height': self.height,
            'explored': self.explored,
            'walkable': walkable,
            'transparent': transparent
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        width = json_data.get('width')
        height = json_data.get('height')
        explored = json_data.get('explored')
        walkable = json_data.get('walkable')
        transparent = json_data.get('transparent')

        game_map = GameMap(width, height)
        game_map.explored = explored

        for y in range(height):
            for x in range(width):
                game_map.walkable[x, y] = walkable[y][x]
                game_map.transparent[x, y] = transparent[y][x]

        return game_map
...

That's a lot of code, so let's break it down a bit, starting with the to_json method.

    def to_json(self):
        walkable = []
        transparent = []

What's the need for a walkable and transparent list? The walkable and transparent attributes in the tdl implementation of Map (the class we're inheriting from) cannot be saved directly to JSON, so we'll set up a 2d list for those and fill them with True and False values.

        for y in range(self.height):
            walkable_row = []
            transparent_row = []

            for x in range(self.width):
                if self.walkable[x, y]:
                    walkable_value = True
                else:
                    walkable_value = False

                if self.transparent[x, y]:
                    transparent_value = True
                else:
                    transparent_value = False

                walkable_row.append(walkable_value)
                transparent_row.append(transparent_value)

            walkable.append(walkable_row)
            transparent.append(transparent_row)

This loops through the map, saving a True or False value to the 2d arrays depending on their attributes in the map class.

        json_data = {
            'width': self.width,
            'height': self.height,
            'explored': self.explored,
            'walkable': walkable,
            'transparent': transparent
        }

        return json_data

We store the width, height, and explored attributes directly, since they're just an int, int and 2d array, respectively. We then also add the walkable and transparent lists we just set up. We return the json_data dictionary, which fully encapsulates our GameMap class into a dictionary that can be saved as JSON. Each one of our to_json methods in this chapter will do something like this.

Now, on to the from_json method, which turns the JSON data like we just created back into the object.

@staticmethod
    def from_json(json_data):
        width = json_data.get('width')
        height = json_data.get('height')
        explored = json_data.get('explored')
        walkable = json_data.get('walkable')
        transparent = json_data.get('transparent')

What does @staticmethod mean? Long story short, it means that even though this method is part of the GameMap class, it doesn't need an actual GameMap to operate on. It could just as easily exist outside the GameMap class. In this case, I've made all the from_json methods as staticmethods, because despite them not operating on a specific instance, they all relate specifically to one, and only one, class. If you prefer, you could name this function game_map_from_json and put it elsewhere.

The from_json method takes an argument, json_data (which should be identical to the one we returned in to_json). We start the function by getting the values out of it, for convenience.

        game_map = GameMap(width, height)
        game_map.explored = explored

We instantiate a new GameMap, just like we did in the main game. The constructor for GameMap takes the width and height, which we stored directly in JSON, and we can also immediately set the explored attribute to equal what we saved in the file. A cleaner way of doing this might be to alter the __init__ method of GameMap to accept the explored 2d list, but in the interest of staying compatible with the main tutorial, we'll just set it immediately after creating the map.

        for y in range(height):
            for x in range(width):
                game_map.walkable[x, y] = walkable[y][x]
                game_map.transparent[x, y] = transparent[y][x]

        return game_map

We can't set the walkable and transparent attributes directly, because tdl doesn't store them as 2d lists. But the translation is simple enough. Once that's all done, we return our new game_map.

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

Things are getting a bit more complicated now; it's not enough to translate the MessageLog into a JSON compatible dictionary, we have to do the same for the Message objects inside it too.

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.

    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

We can save x, width, and height directly, they're just integers. messages should be a list, obviously, but it's a list of Message objects. Luckily, we just wrote the code to transform each Message into JSON compatible dictionaries, so all we have to do it loop through our messages, calling to_json on each one. Python's list comprehensions make this dead simple!

    @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

Loading x, width, and height are easy enough, and we can just pass them into the MessageLog constructor as is. After we create a new MessageLog, we need to re-add all the messages that were there before. Again, we already wrote the code to transform a JSON message into the Message class, so this really isn't any more difficult than calling the from_json on Message and passing the relevant JSON (which was nested in the message log JSON).

I said that the message log was more complicated, but really, it wasn't that bad either! But it does give us a taste of how to pack and unpack nested objects using our to_json and from_json methods, which will be very relevant for the final part: Entities.

Entities are more complex than both the GameMap and MessageLog because we have to worry about our components. We'll not only need a to_json and from_json method on the Entity class, we'll need them on each component as well.

Let's do the JSON to and from methods on each component, then we'll finish up with the Entity. First up is Fighter.

...
class Fighter:
    def __init__(self, hp, defense, power):
        self.max_hp = hp
        self.hp = hp
        self.defense = defense
        self.power = power
    ...

    def to_json(self):
        json_data = {
            'max_hp': self.max_hp,
            'hp': self.hp,
            'defense': self.defense,
            'power': self.power
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        max_hp = json_data.get('max_hp')
        hp = json_data.get('hp')
        defense = json_data.get('defense')
        power = json_data.get('power')

        fighter = Fighter(max_hp, defense, power)
        fighter.hp = hp

        return fighter

There's nothing too complicated here, because the Fighter data consists of only integers, which save perfectly fine to JSON. The only thing worth noting is that we have to set the hp manually after instantiating the Fighter, because the __init__ function assumes the fighter is being created with full health. If you're loading the game from a save file, that may not be the case. Like with the GameMap before, a better way might be to alter the constructor, but in the interest of keeping consistent with the base tutorial, we won't do that here.

Next up is our AI. Open up ai.py and modify the classes like this:

class BasicMonster:
    def take_turn(self, target, game_map, entities):
        ...

    def to_json(self):
        json_data = {
            'name': self.__class__.__name__
        }

        return json_data

    @staticmethod
    def from_json():
        basic_monster = BasicMonster()

        return basic_monster


class ConfusedMonster:
    def __init__(self, previous_ai, number_of_turns=10):
        self.previous_ai = previous_ai
        self.number_of_turns = number_of_turns

    def take_turn(self, target, game_map, entities):
        ...

    def to_json(self):
        json_data = {
            'name': self.__class__.__name__,
            'previous_ai': self.previous_ai.__class__.__name__,
            'number_of_turns': self.number_of_turns
        }

        return json_data

    @staticmethod
    def from_json(json_data, owner):
        previous_ai_name = json_data.get('previous_ai')
        number_of_turns = json_data.get('number_of_turns')

        if previous_ai_name == 'BasicMonster':
            previous_ai = BasicMonster()
            previous_ai.owner = owner
        else:
            previous_ai = None

        confused_monster = ConfusedMonster(previous_ai, number_of_turns)

        return confused_monster

The to and from methods for BasicMonster are the simplest yet, because the BasicMonster class doesn't actually hold any data. All we need is the name of the class so that our Entity will know which AI to assign to itself.

What's with the self.__class__.__name__ part though? That just gets a string representation of the class, in this case, it's the string 'BasicMonster'. We could just type that out ourselves, and it would arguably make our code more clear and easy to understand. The benefit of this approach, however, is that if you do end up changing the class name, you don't have to remember to update this section.

The from_json method on BasicMonster is really kind of useless right now. I have two reasons for including it: 1. Consistency. 2. If you do make this class more complex later on (maybe the monster has a 'fear' or 'aggression' attribute), you'll need this function.

The ConfusedMonster methods are a lot more interesting, mostly because we not only need to consider the ConfusedMonster class, but the previous_ai that it stores. The previous_ai is also stored as just the name of the class, so that part is simple. But when we load it we need to call that class and assign it to ConfusedMonster. Note that we also need to set the owner of previous_ai, so we pass that in to the from_json method as well.

Now for the Item component. The easy parts of this component are targeting (it's just a boolean) and function_kwargs, since that's just a dictionary. targeting_message takes special consideration, but we've already written the JSON serializer for the Message class, so we just need to use it. use_function is trickier, because we can only save the name of the function, and we have to reload the function based on name alone.

from game_messages import Message

import item_functions


class Item:
    def __init__(self, use_function=None, targeting=False, targeting_message=None, **kwargs):
        self.use_function = use_function
        self.targeting = targeting
        self.targeting_message = targeting_message
        self.function_kwargs = kwargs

    def to_json(self):
        if self.targeting_message:
            targeting_message_json = self.targeting_message.to_json()
        else:
            targeting_message_json = None

        json_data = {
            'use_function': self.use_function.__name__,
            'targeting': self.targeting,
            'targeting_message': targeting_message_json,
            'function_kwargs': self.function_kwargs
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        use_function_name = json_data.get('use_function')
        targeting = json_data.get('targeting')
        targeting_message_json = json_data.get('targeting_message')
        function_kwargs = json_data.get('function_kwargs', {})

        if use_function_name:
            use_function = getattr(item_functions, use_function_name)
        else:
            use_function = None

        if targeting_message_json:
            targeting_message = Message.from_json(targeting_message_json)
        else:
            targeting_message = None

        item = Item(use_function, targeting, targeting_message, **function_kwargs)

        return item

Saving is pretty straightforward, but loading is where things get a little tricky. We use the Message from_json function to load the message, if one exists. The function_kwargs need the double asterisks to denote that they should function as keyword arguments.

What's going on with use_function_name though? getattr() can be used to get attributes from an object based on a name. In this case, we're getting the "attribute" from the item_function module itself. The "attribute" we're getting, in this case, is the function. Basically, the getattr() function lets us get the function based on its name (assuming it's located in item_functions, which we know is true in this case).

Now for our final component: Inventory. This one is pretty easy, because the it only holds its capacity and a list of each item in it. Note that the inventory actually holds references to Entity, so it will need to call the to_json and from_json methods of Entity, which we haven't written yet (we will right after this).

from game_messages import Message


class Inventory:
    def __init__(self, capacity):
        self.capacity = capacity
        self.items = []
    ...
    def to_json(self):
        json_data = {
            'capacity': self.capacity,
            'items': [item.to_json() for item in self.items]
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        from entity import Entity

        capacity = json_data.get('capacity')
        items_json = json_data.get('items')

        items = [Entity.from_json(item_json) for item_json in items_json]

        inventory = Inventory(capacity)
        inventory.items = items

        return inventory

We have to manually set the items dictionary after initialization. Other than that, this is pretty straightforward.

Why are we importing the Entity class in the from_json method? It's to avoid a circular reference. Python throws an error if we try to import it at the top.

Finally, let's implement the to_json and from_json methods for Entity. We'll need to call upon all the component methods we just wrote, as well as save the Entity data as well.

import math

from components.fighter import Fighter
from components.ai import BasicMonster, ConfusedMonster
from components.item import Item
from components.inventory import Inventory

from render_functions import RenderOrder


class Entity:
    ...
    def to_json(self):
        if self.fighter:
            fighter_data = self.fighter.to_json()
        else:
            fighter_data = None

        if self.ai:
            ai_data = self.ai.to_json()
        else:
            ai_data = None

        if self.item:
            item_data = self.item.to_json()
        else:
            item_data = None

        if self.inventory:
            inventory_data = self.inventory.to_json()
        else:
            inventory_data = None

        json_data = {
            'x': self.x,
            'y': self.y,
            'char': self.char,
            'color': self.color,
            'name': self.name,
            'blocks': self.blocks,
            'render_order': self.render_order.value,
            'fighter': fighter_data,
            'ai': ai_data,
            'item': item_data,
            'inventory': inventory_data
        }

        return json_data

    @staticmethod
    def from_json(json_data):
        x = json_data.get('x')
        y = json_data.get('y')
        char = json_data.get('char')
        color = json_data.get('color')
        name = json_data.get('name')
        blocks = json_data.get('blocks', False)
        render_order = RenderOrder(json_data.get('render_order'))
        fighter_json = json_data.get('fighter')
        ai_json = json_data.get('ai')
        item_json = json_data.get('item')
        inventory_json = json_data.get('inventory')

        entity = Entity(x, y, char, color, name, blocks, render_order)

        if fighter_json:
            entity.fighter = Fighter.from_json(fighter_json)
            entity.fighter.owner = entity

        if ai_json:
            name = ai_json.get('name')

            if name == BasicMonster.__name__:
                ai = BasicMonster.from_json()
            elif name == ConfusedMonster.__name__:
                ai = ConfusedMonster.from_json(ai_json, entity)
            else:
                ai = None

            if ai:
                entity.ai = ai
                entity.ai.owner = entity

        if item_json:
            entity.item = Item.from_json(item_json)
            entity.item.owner = entity

        if inventory_json:
            entity.inventory = Inventory.from_json(inventory_json)
            entity.inventory.owner = entity

        return entity

The to_json method is pretty simple: we call upon the component's to_json method if the component exists in the Entity, and if not, we just save None. Note that like our GameState, we're just saving the value of RenderOrder.

In from_json we setup our Entity, and, if JSON was found for each component, we recreate it using that component's from_json method, and assign it to the entity. Also note that we specify the owner of each component afterwards. The AI section checks which AI the Entity has, and calls upon different classes depending on what the name is.

And that's all we need! The functions at the top should now work as expected. If you want to use these functions in your game, be sure to replace the save_game and load_game functions from data_loaders with the ones from json_loaders, like this:

...
from loader_functions.initialize_new_game import get_constants, get_game_variables
from loader_functions.data_loaders import load_game, save_game
from loader_functions.json_loaders import load_game, save_game
from menus import main_menu, message_box
...

No other changes are needed in engine.py! Run the project now, and you should be able to save and load from a JSON file.