Part 10 - Saving and loading

Saving and loading is essential to almost every roguelike, but it can be a pain to manage if you don’t start early. By the end of this chapter, our game will be able to save and load one file to the disk, which you could easily expand to multiple saves if you wanted to.

Let’s start by defining the colors we’ll need this chapter, by opening color.py and entering the following:

...
bar_empty = (0x40, 0x10, 0x10)

+menu_title = (255, 255, 63)
+menu_text = white
...
bar_empty = (0x40, 0x10, 0x10)

menu_title = (255, 255, 63)
menu_text = white

Another thing we’ll need is a new type of exception. This will be used when we want to quit the game, but not save it. Normally, we’ll save the game when the user quits, but if the game is over (because the player is dead), we don’t want to create a save file.

We can put this exception in exceptions.py:

class Impossible(Exception):
    """Exception raised when an action is impossible to be performed.

    The reason is given as the exception message.
    """


+class QuitWithoutSaving(SystemExit):
+   """Can be raised to exit the game without automatically saving."""
class Impossible(Exception):
    """Exception raised when an action is impossible to be performed.

    The reason is given as the exception message.
    """


class QuitWithoutSaving(SystemExit):
    """Can be raised to exit the game without automatically saving."""

There’s a bit of refactoring we can do to make things easier for ourselves in the future: By creating a base class that can be either an Action or an EventHandler, we don’t need to set the engine’s “event handler” to the new handler when we want to switch, we can just return that event handler instead. A benefit of this is that the Engine class won’t need to store the event handler anymore. This works by keeping track of the handler in the main.py file instead, and switching it when necessary.

To make the change, start by adding the following to input_handlers.py:

from __future__ import annotations

-from typing import Callable, Optional, Tuple, TYPE_CHECKING
+from typing import Callable, Optional, Tuple, TYPE_CHECKING, Union

import tcod
...

...
CONFIRM_KEYS = {
    tcod.event.K_RETURN,
    tcod.event.K_KP_ENTER,
}


+ActionOrHandler = Union[Action, "BaseEventHandler"]
+"""An event handler return value which can trigger an action or switch active handlers.

+If a handler is returned then it will become the active handler for future events.
+If an action is returned it will be attempted and if it's valid then
+MainGameEventHandler will become the active handler.
+"""


+class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]):
+   def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
+       """Handle an event and return the next active event handler."""
+       state = self.dispatch(event)
+       if isinstance(state, BaseEventHandler):
+           return state
+       assert not isinstance(state, Action), f"{self!r} can not handle actions."
+       return self

+   def on_render(self, console: tcod.Console) -> None:
+       raise NotImplementedError()

+   def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
+       raise SystemExit()
from __future__ import annotations

from typing import Callable, Optional, Tuple, TYPE_CHECKING
from typing import Callable, Optional, Tuple, TYPE_CHECKING, Union

import tcod
...

...
CONFIRM_KEYS = {
    tcod.event.K_RETURN,
    tcod.event.K_KP_ENTER,
}


ActionOrHandler = Union[Action, "BaseEventHandler"]
"""An event handler return value which can trigger an action or switch active handlers.

If a handler is returned then it will become the active handler for future events.
If an action is returned it will be attempted and if it's valid then
MainGameEventHandler will become the active handler.
"""


class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]):
    def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
        """Handle an event and return the next active event handler."""
        state = self.dispatch(event)
        if isinstance(state, BaseEventHandler):
            return state
        assert not isinstance(state, Action), f"{self!r} can not handle actions."
        return self

    def on_render(self, console: tcod.Console) -> None:
        raise NotImplementedError()

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

As the docstring explains, ActionOrHandler can be either an Action or an EventHandler. If it’s an Action, the action will be attempted, and if its a handler, the handler will be changed.

BaseEventHandler will be the base class for all of our handlers (we’ll change that to be the case next). It will return a new instance of BaseEventHandler or its subclasses if one was returned, or return itself. This allows us to change event handlers based on the context of what happens in the actions.

We also need to adjust EventHandler:

-class EventHandler(tcod.event.EventDispatch[Action]):
+class EventHandler(BaseEventHandler):
    def __init__(self, engine: Engine):
        self.engine = engine

-   def handle_events(self, event: tcod.event.Event) -> None:
-       self.handle_action(self.dispatch(event))
+   def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
+       """Handle events for input handlers with an engine."""
+       action_or_state = self.dispatch(event)
+       if isinstance(action_or_state, BaseEventHandler):
+           return action_or_state
+       if self.handle_action(action_or_state):
+           # A valid action was performed.
+           if not self.engine.player.is_alive:
+               # The player was killed sometime during or after the action.
+               return GameOverEventHandler(self.engine)
+           return MainGameEventHandler(self.engine)  # Return to the main handler.
+       return self

    def handle_action(self, action: Optional[Action]) -> bool:
        ...

-   def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
-       raise SystemExit()

    def on_render(self, console: tcod.Console) -> None:
        self.engine.render(console)
class EventHandler(tcod.event.EventDispatch[Action]):
class EventHandler(BaseEventHandler):
    def __init__(self, engine: Engine):
        self.engine = engine

    def handle_events(self, event: tcod.event.Event) -> None:
        self.handle_action(self.dispatch(event))
    def handle_events(self, event: tcod.event.Event) -> BaseEventHandler:
        """Handle events for input handlers with an engine."""
        action_or_state = self.dispatch(event)
        if isinstance(action_or_state, BaseEventHandler):
            return action_or_state
        if self.handle_action(action_or_state):
            # A valid action was performed.
            if not self.engine.player.is_alive:
                # The player was killed sometime during or after the action.
                return GameOverEventHandler(self.engine)
            return MainGameEventHandler(self.engine)  # Return to the main handler.
        return self

    def handle_action(self, action: Optional[Action]) -> bool:
        ...

    def ev_quit(self, event: tcod.event.Quit) -> Optional[Action]:
        raise SystemExit()

    def on_render(self, console: tcod.Console) -> None:
        self.engine.render(console)

The handle_events method of EventHandler is similar to BaseEventHandler, except it includes logic to handle actions as well. It also contains the logic for changing our handler to a game over if the player is dead.

To adjust our existing handlers, we’ll need to continue editing input_handlers. This next code section is quite long, but the idea is consistent throughout: We want to modify our return types to return Optional[ActionOrHandler] instead of Optional[Action], and instead of setting self.engine.event_handler to change the handler, we’ll return the handler instead.

class AskUserEventHandler(EventHandler):
    """Handles user input for actions which require special input."""

-   def handle_action(self, action: Optional[Action]) -> bool:
-       """Return to the main event handler when a valid action was performed."""
-       if super().handle_action(action):
-           self.engine.event_handler = MainGameEventHandler(self.engine)
-           return True
-       return False

-   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        """By default any key exits this input handler."""
        if event.sym in {  # Ignore modifier keys.
            tcod.event.K_LSHIFT,
            tcod.event.K_RSHIFT,
            tcod.event.K_LCTRL,
            tcod.event.K_RCTRL,
            tcod.event.K_LALT,
            tcod.event.K_RALT,
        }:
            return None
        return self.on_exit()

-   def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]:
+   def ev_mousebuttondown(
+       self, event: tcod.event.MouseButtonDown
+   ) -> Optional[ActionOrHandler]:
        """By default any mouse click exits this input handler."""
        return self.on_exit()

-   def on_exit(self) -> Optional[Action]:
+   def on_exit(self) -> Optional[ActionOrHandler]:
        """Called when the user is trying to exit or cancel an action.

        By default this returns to the main event handler.
        """
-       self.engine.event_handler = MainGameEventHandler(self.engine)
-       return None
+       return MainGameEventHandler(self.engine)


class InventoryEventHandler(AskUserEventHandler):
    ...

-   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        player = self.engine.player
        key = event.sym
        index = key - tcod.event.K_a

        if 0 <= index <= 26:
            try:
                selected_item = player.inventory.items[index]
            except IndexError:
                self.engine.message_log.add_message("Invalid entry.", color.invalid)
                return None
            return self.on_item_selected(selected_item)
        return super().ev_keydown(event)

-   def on_item_selected(self, item: Item) -> Optional[Action]:
+   def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Called when the user selects a valid item."""
        raise NotImplementedError()


class InventoryActivateHandler(InventoryEventHandler):
    """Handle using an inventory item."""

    TITLE = "Select an item to use"

-   def on_item_selected(self, item: Item) -> Optional[Action]:
+   def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Return the action for the selected item."""
        return item.consumable.get_action(self.engine.player)


class InventoryDropHandler(InventoryEventHandler):
    """Handle dropping an inventory item."""

    TITLE = "Select an item to drop"

-   def on_item_selected(self, item: Item) -> Optional[Action]:
+   def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Drop this item."""
        return actions.DropItem(self.engine.player, item)


class SelectIndexHandler(AskUserEventHandler):
    ...

-   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        ...

-   def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]:
+   def ev_mousebuttondown(
+       self, event: tcod.event.MouseButtonDown
+   ) -> Optional[ActionOrHandler]:
        ...

-   def on_index_selected(self, x: int, y: int) -> Optional[Action]:
+   def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
        """Called when an index is selected."""
        raise NotImplementedError()


class LookHandler(SelectIndexHandler):
    """Lets the player look around using the keyboard."""

-   def on_index_selected(self, x: int, y: int) -> None:
+   def on_index_selected(self, x: int, y: int) -> MainGameEventHandler:
        """Return to main handler."""
-       self.engine.event_handler = MainGameEventHandler(self.engine)
+       return MainGameEventHandler(self.engine)

...

class MainGameEventHandler(EventHandler):
-   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        action: Optional[Action] = None

        key = event.sym

        player = self.engine.player

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
            action = BumpAction(player, dx, dy)
        elif key in WAIT_KEYS:
            action = WaitAction(player)

        elif key == tcod.event.K_ESCAPE:
            raise SystemExit()
        elif key == tcod.event.K_v:
-           self.engine.event_handler = HistoryViewer(self.engine)
+           return HistoryViewer(self.engine)

        elif key == tcod.event.K_g:
            action = PickupAction(player)

        elif key == tcod.event.K_i:
-           self.engine.event_handler = InventoryActivateHandler(self.engine)
+           return InventoryActivateHandler(self.engine)
        elif key == tcod.event.K_d:
-           self.engine.event_handler = InventoryDropHandler(self.engine)
+           return InventoryDropHandler(self.engine)
        elif key == tcod.event.K_SLASH:
-           self.engine.event_handler = LookHandler(self.engine)
+           return LookHandler(self.engine)

        # No valid key was pressed
        return action


...
class HistoryViewer(EventHandler):
    ...

-   def ev_keydown(self, event: tcod.event.KeyDown) -> None:
+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]:
        # Fancy conditional movement to make it feel right.
        if event.sym in CURSOR_Y_KEYS:
            adjust = CURSOR_Y_KEYS[event.sym]
            if adjust < 0 and self.cursor == 0:
                # Only move from the top to the bottom when you're on the edge.
                self.cursor = self.log_length - 1
            elif adjust > 0 and self.cursor == self.log_length - 1:
                # Same with bottom to top movement.
                self.cursor = 0
            else:
                # Otherwise move while staying clamped to the bounds of the history log.
                self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1))
        elif event.sym == tcod.event.K_HOME:
            self.cursor = 0  # Move directly to the top message.
        elif event.sym == tcod.event.K_END:
            self.cursor = self.log_length - 1  # Move directly to the last message.
        else:  # Any other key moves back to the main game state.
-           self.engine.event_handler = MainGameEventHandler(self.engine)
+           return MainGameEventHandler(self.engine)
+       return None
class AskUserEventHandler(EventHandler):
    """Handles user input for actions which require special input."""

    def handle_action(self, action: Optional[Action]) -> bool:
        """Return to the main event handler when a valid action was performed."""
        if super().handle_action(action):
            self.engine.event_handler = MainGameEventHandler(self.engine)
            return True
        return False

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        """By default any key exits this input handler."""
        if event.sym in {  # Ignore modifier keys.
            tcod.event.K_LSHIFT,
            tcod.event.K_RSHIFT,
            tcod.event.K_LCTRL,
            tcod.event.K_RCTRL,
            tcod.event.K_LALT,
            tcod.event.K_RALT,
        }:
            return None
        return self.on_exit()

    def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]:
    def ev_mousebuttondown(
        self, event: tcod.event.MouseButtonDown
    ) -> Optional[ActionOrHandler]:
        """By default any mouse click exits this input handler."""
        return self.on_exit()

    def on_exit(self) -> Optional[Action]:
    def on_exit(self) -> Optional[ActionOrHandler]:
        """Called when the user is trying to exit or cancel an action.

        By default this returns to the main event handler.
        """
        self.engine.event_handler = MainGameEventHandler(self.engine)
        return None
        return MainGameEventHandler(self.engine)


class InventoryEventHandler(AskUserEventHandler):
    ...

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        player = self.engine.player
        key = event.sym
        index = key - tcod.event.K_a

        if 0 <= index <= 26:
            try:
                selected_item = player.inventory.items[index]
            except IndexError:
                self.engine.message_log.add_message("Invalid entry.", color.invalid)
                return None
            return self.on_item_selected(selected_item)
        return super().ev_keydown(event)

    def on_item_selected(self, item: Item) -> Optional[Action]:
    def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Called when the user selects a valid item."""
        raise NotImplementedError()


class InventoryActivateHandler(InventoryEventHandler):
    """Handle using an inventory item."""

    TITLE = "Select an item to use"

    def on_item_selected(self, item: Item) -> Optional[Action]:
    def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Return the action for the selected item."""
        return item.consumable.get_action(self.engine.player)


class InventoryDropHandler(InventoryEventHandler):
    """Handle dropping an inventory item."""

    TITLE = "Select an item to drop"

    def on_item_selected(self, item: Item) -> Optional[Action]:
    def on_item_selected(self, item: Item) -> Optional[ActionOrHandler]:
        """Drop this item."""
        return actions.DropItem(self.engine.player, item)


class SelectIndexHandler(AskUserEventHandler):
    ...

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        ...

    def ev_mousebuttondown(self, event: tcod.event.MouseButtonDown) -> Optional[Action]:
    def ev_mousebuttondown(
        self, event: tcod.event.MouseButtonDown
    ) -> Optional[ActionOrHandler]:
        ...

    def on_index_selected(self, x: int, y: int) -> Optional[Action]:
    def on_index_selected(self, x: int, y: int) -> Optional[ActionOrHandler]:
        """Called when an index is selected."""
        raise NotImplementedError()


class LookHandler(SelectIndexHandler):
    """Lets the player look around using the keyboard."""

    def on_index_selected(self, x: int, y: int) -> None:
    def on_index_selected(self, x: int, y: int) -> MainGameEventHandler:
        """Return to main handler."""
        self.engine.event_handler = MainGameEventHandler(self.engine)
        return MainGameEventHandler(self.engine)

...

class MainGameEventHandler(EventHandler):
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[Action]:
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[ActionOrHandler]:
        action: Optional[Action] = None

        key = event.sym

        player = self.engine.player

        if key in MOVE_KEYS:
            dx, dy = MOVE_KEYS[key]
            action = BumpAction(player, dx, dy)
        elif key in WAIT_KEYS:
            action = WaitAction(player)

        elif key == tcod.event.K_ESCAPE:
            raise SystemExit()
        elif key == tcod.event.K_v:
            self.engine.event_handler = HistoryViewer(self.engine)
            return HistoryViewer(self.engine)

        elif key == tcod.event.K_g:
            action = PickupAction(player)

        elif key == tcod.event.K_i:
            self.engine.event_handler = InventoryActivateHandler(self.engine)
            return InventoryActivateHandler(self.engine)
        elif key == tcod.event.K_d:
            self.engine.event_handler = InventoryDropHandler(self.engine)
            return InventoryDropHandler(self.engine)
        elif key == tcod.event.K_SLASH:
            self.engine.event_handler = LookHandler(self.engine)
            return LookHandler(self.engine)

        # No valid key was pressed
        return action


...
class HistoryViewer(EventHandler):
    ...

    def ev_keydown(self, event: tcod.event.KeyDown) -> None:
    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[MainGameEventHandler]:
        # Fancy conditional movement to make it feel right.
        if event.sym in CURSOR_Y_KEYS:
            adjust = CURSOR_Y_KEYS[event.sym]
            if adjust < 0 and self.cursor == 0:
                # Only move from the top to the bottom when you're on the edge.
                self.cursor = self.log_length - 1
            elif adjust > 0 and self.cursor == self.log_length - 1:
                # Same with bottom to top movement.
                self.cursor = 0
            else:
                # Otherwise move while staying clamped to the bounds of the history log.
                self.cursor = max(0, min(self.cursor + adjust, self.log_length - 1))
        elif event.sym == tcod.event.K_HOME:
            self.cursor = 0  # Move directly to the top message.
        elif event.sym == tcod.event.K_END:
            self.cursor = self.log_length - 1  # Move directly to the last message.
        else:  # Any other key moves back to the main game state.
            self.engine.event_handler = MainGameEventHandler(self.engine)
            return MainGameEventHandler(self.engine)
        return None

We’ll also need to make a few adjustments in consumable.py, because some of the methods there also affected the input handlers.

...
import components.ai
import components.inventory
from components.base_component import BaseComponent
from exceptions import Impossible
-from input_handlers import AreaRangedAttackHandler, SingleRangedAttackHandler
+from input_handlers import (
+   ActionOrHandler,
+   AreaRangedAttackHandler,
+   SingleRangedAttackHandler,
+)

if TYPE_CHECKING:
    ...

...
class Consumable(BaseComponent):
    parent: Item

-   def get_action(self, consumer: Actor) -> Optional[actions.Action]:
+   def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]:
        """Try to return the action for this item."""
        return actions.ItemAction(consumer, self.parent)

    ...

class ConfusionConsumable(Consumable):
    def __init__(self, number_of_turns: int):
        self.number_of_turns = number_of_turns

-   def get_action(self, consumer: Actor) -> Optional[actions.Action]:
+   def get_action(self, consumer: Actor) -> SingleRangedAttackHandler:
        self.engine.message_log.add_message(
            "Select a target location.", color.needs_target
        )
-       self.engine.event_handler = SingleRangedAttackHandler(
+       return SingleRangedAttackHandler(
            self.engine,
            callback=lambda xy: actions.ItemAction(consumer, self.parent, xy),
        )
-       return None

    ...

class FireballDamageConsumable(Consumable):
    def __init__(self, damage: int, radius: int):
        self.damage = damage
        self.radius = radius

-   def get_action(self, consumer: Actor) -> Optional[actions.Action]:
+   def get_action(self, consumer: Actor) -> AreaRangedAttackHandler:
        self.engine.message_log.add_message(
            "Select a target location.", color.needs_target
        )
-       self.engine.event_handler = AreaRangedAttackHandler(
+       return AreaRangedAttackHandler(
            self.engine,
            radius=self.radius,
            callback=lambda xy: actions.ItemAction(consumer, self.parent, xy),
        )
-       return None

    def activate(self, action: actions.ItemAction) -> None:
        ...
...
import components.ai
import components.inventory
from components.base_component import BaseComponent
from exceptions import Impossible
from input_handlers import AreaRangedAttackHandler, SingleRangedAttackHandler
from input_handlers import (
    ActionOrHandler,
    AreaRangedAttackHandler,
    SingleRangedAttackHandler,
)

if TYPE_CHECKING:
    ...

...
class Consumable(BaseComponent):
    parent: Item

    def get_action(self, consumer: Actor) -> Optional[actions.Action]:
    def get_action(self, consumer: Actor) -> Optional[ActionOrHandler]:
        """Try to return the action for this item."""
        return actions.ItemAction(consumer, self.parent)

    ...

class ConfusionConsumable(Consumable):
    def __init__(self, number_of_turns: int):
        self.number_of_turns = number_of_turns

    def get_action(self, consumer: Actor) -> Optional[actions.Action]:
    def get_action(self, consumer: Actor) -> SingleRangedAttackHandler:
        self.engine.message_log.add_message(
            "Select a target location.", color.needs_target
        )
        self.engine.event_handler = SingleRangedAttackHandler(
        return SingleRangedAttackHandler(
            self.engine,
            callback=lambda xy: actions.ItemAction(consumer, self.parent, xy),
        )
        return None

    ...

class FireballDamageConsumable(Consumable):
    def __init__(self, damage: int, radius: int):
        self.damage = damage
        self.radius = radius

    def get_action(self, consumer: Actor) -> Optional[actions.Action]:
    def get_action(self, consumer: Actor) -> AreaRangedAttackHandler:
        self.engine.message_log.add_message(
            "Select a target location.", color.needs_target
        )
        self.engine.event_handler = AreaRangedAttackHandler(
        return AreaRangedAttackHandler(
            self.engine,
            radius=self.radius,
            callback=lambda xy: actions.ItemAction(consumer, self.parent, xy),
        )
        return None

    def activate(self, action: actions.ItemAction) -> None:
        ...

We also need to make a small adjustmet to fighter.py:

from typing import TYPE_CHECKING

import color
from components.base_component import BaseComponent
-from input_handlers import GameOverEventHandler
from render_order import RenderOrder

...

        ...
        if self.engine.player is self.parent:
            death_message = "You died!"
            death_message_color = color.player_die
-           self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.parent.name} is dead!"
            death_message_color = color.enemy_die
        ...
from typing import TYPE_CHECKING

import color
from components.base_component import BaseComponent
from input_handlers import GameOverEventHandler
from render_order import RenderOrder

...

        ...
        if self.engine.player is self.parent:
            death_message = "You died!"
            death_message_color = color.player_die
            self.engine.event_handler = GameOverEventHandler(self.engine)
        else:
            death_message = f"{self.parent.name} is dead!"
            death_message_color = color.enemy_die
        ...

Since the logic associated with setting GameOverEventHandler is now handled in the EventHandler class, this line in fighter.py wasn’t needed anymore.

In order to make these changes work, we need to adjust the way that the input handlers are… well, handled. What we’ll want to do is have the handler exist on its own in main.py rather than be part of the Engine class.

Open up main.py and add make the following changes:

#!/usr/bin/env python3
import copy
import traceback

import tcod

import color
from engine import Engine
import entity_factories
+import exceptions
+import input_handlers
from procgen import generate_dungeon

def main() -> None:
    ...
    engine.message_log.add_message(
        "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
    )

+   handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine)

    with tcod.context.new_terminal(
        screen_width,
        screen_height,
        tileset=tileset,
        title="Yet Another Roguelike Tutorial",
        vsync=True,
    ) as context:
        root_console = tcod.Console(screen_width, screen_height, order="F")
-       while True:
-           root_console.clear()
-           engine.event_handler.on_render(console=root_console)
-           context.present(root_console)

-           try:
-               for event in tcod.event.wait():
-                   context.convert_event(event)
-                   engine.event_handler.handle_events(event)
-           except Exception:  # Handle exceptions in game.
-               traceback.print_exc()  # Print error to stderr.
-               # Then print the error to the message log.
-               engine.message_log.add_message(traceback.format_exc(), color.error)
+       try:
+           while True:
+               root_console.clear()
+               handler.on_render(console=root_console)
+               context.present(root_console)

+               try:
+                   for event in tcod.event.wait():
+                       context.convert_event(event)
+                       handler = handler.handle_events(event)
+               except Exception:  # Handle exceptions in game.
+                   traceback.print_exc()  # Print error to stderr.
+                   # Then print the error to the message log.
+                   if isinstance(handler, input_handlers.EventHandler):
+                       handler.engine.message_log.add_message(
+                           traceback.format_exc(), color.error
+                       )
+       except exceptions.QuitWithoutSaving:
+           raise
+       except SystemExit:  # Save and quit.
+           # TODO: Add the save function here
+           raise
+       except BaseException:  # Save on any other unexpected exception.
+           # TODO: Add the save function here
+           raise
#!/usr/bin/env python3
import copy
import traceback

import tcod

import color
from engine import Engine
import entity_factories
import exceptions
import input_handlers
from procgen import generate_dungeon

def main() -> None:
    ...
    engine.message_log.add_message(
        "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
    )

    handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine)

    with tcod.context.new_terminal(
        screen_width,
        screen_height,
        tileset=tileset,
        title="Yet Another Roguelike Tutorial",
        vsync=True,
    ) as context:
        root_console = tcod.Console(screen_width, screen_height, order="F")
        while True:
            root_console.clear()
            engine.event_handler.on_render(console=root_console)
            context.present(root_console)

            try:
                for event in tcod.event.wait():
                    context.convert_event(event)
                    engine.event_handler.handle_events(event)
            except Exception:  # Handle exceptions in game.
                traceback.print_exc()  # Print error to stderr.
                # Then print the error to the message log.
                engine.message_log.add_message(traceback.format_exc(), color.error)
        try:
            while True:
                root_console.clear()
                handler.on_render(console=root_console)
                context.present(root_console)

                try:
                    for event in tcod.event.wait():
                        context.convert_event(event)
                        handler = handler.handle_events(event)
                except Exception:  # Handle exceptions in game.
                    traceback.print_exc()  # Print error to stderr.
                    # Then print the error to the message log.
                    if isinstance(handler, input_handlers.EventHandler):
                        handler.engine.message_log.add_message(
                            traceback.format_exc(), color.error
                        )
        except exceptions.QuitWithoutSaving:
            raise
        except SystemExit:  # Save and quit.
            # TODO: Add the save function here
            raise
        except BaseException:  # Save on any other unexpected exception.
            # TODO: Add the save function here
            raise

We’re now defining our handler in main.py rather than passing it to the Engine. The handler can change if a different handler is returned from handler.handle_events. We’ve also added a few exception statements for the various exception types. They all do the same thing at the moment, but soon, once we’ve implemented our save function, the SystemExit and BaseException exceptions will save the game before exiting. QuitWithoutSaving will not, as this will be called when we don’t want to save the game.

If you run the project now, things should work the same as before.

Let’s clean up the Engine class by removing the event_handler attribute, as we don’t need it anymore:

from __future__ import annotations

from typing import TYPE_CHECKING

from tcod.console import Console
from tcod.map import compute_fov

import exceptions
-from input_handlers import MainGameEventHandler
from message_log import MessageLog
from render_functions import (
    render_bar,
    render_names_at_mouse_location,
)

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
-   from input_handlers import EventHandler


class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
-       self.event_handler: EventHandler = MainGameEventHandler(self)
        self.message_log = MessageLog()
        self.mouse_location = (0, 0)
        self.player = player
        ...
from __future__ import annotations

from typing import TYPE_CHECKING

from tcod.console import Console
from tcod.map import compute_fov

import exceptions
from input_handlers import MainGameEventHandler
from message_log import MessageLog
from render_functions import (
    render_bar,
    render_names_at_mouse_location,
)

if TYPE_CHECKING:
    from entity import Actor
    from game_map import GameMap
    from input_handlers import EventHandler


class Engine:
    game_map: GameMap

    def __init__(self, player: Actor):
        self.event_handler: EventHandler = MainGameEventHandler(self)
        self.message_log = MessageLog()
        self.mouse_location = (0, 0)
        self.player = player
        ...

If you run the project again, nothing should change. The event_handler in Engine was not doing anything at this point.

Before we implement saving the game, we need to implement a main menu, where the user can choose to start a new game or load an existing one (or simply quit). It would also be handy to move all the logic of setting up a new game into a function, as we won’t need to call it when we eventually get to loading a game from a save file.

Create a new file, called setup_game.py, and put the following contents into it:

"""Handle the loading and initialization of game sessions."""
from __future__ import annotations

import copy
from typing import Optional

import tcod

import color
from engine import Engine
import entity_factories
import input_handlers
from procgen import generate_dungeon


# Load the background image and remove the alpha channel.
background_image = tcod.image.load("menu_background.png")[:, :, :3]


def new_game() -> Engine:
    """Return a brand new game session as an Engine instance."""
    map_width = 80
    map_height = 43

    room_max_size = 10
    room_min_size = 6
    max_rooms = 30

    max_monsters_per_room = 2
    max_items_per_room = 2

    player = copy.deepcopy(entity_factories.player)

    engine = Engine(player=player)

    engine.game_map = generate_dungeon(
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        max_items_per_room=max_items_per_room,
        engine=engine,
    )
    engine.update_fov()

    engine.message_log.add_message(
        "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
    )
    return engine


class MainMenu(input_handlers.BaseEventHandler):
    """Handle the main menu rendering and input."""

    def on_render(self, console: tcod.Console) -> None:
        """Render the main menu on a background image."""
        console.draw_semigraphics(background_image, 0, 0)

        console.print(
            console.width // 2,
            console.height // 2 - 4,
            "TOMBS OF THE ANCIENT KINGS",
            fg=color.menu_title,
            alignment=tcod.CENTER,
        )
        console.print(
            console.width // 2,
            console.height - 2,
            "By (Your name here)",
            fg=color.menu_title,
            alignment=tcod.CENTER,
        )

        menu_width = 24
        for i, text in enumerate(
            ["[N] Play a new game", "[C] Continue last game", "[Q] Quit"]
        ):
            console.print(
                console.width // 2,
                console.height // 2 - 2 + i,
                text.ljust(menu_width),
                fg=color.menu_text,
                bg=color.black,
                alignment=tcod.CENTER,
                bg_blend=tcod.BKGND_ALPHA(64),
            )

    def ev_keydown(
        self, event: tcod.event.KeyDown
    ) -> Optional[input_handlers.BaseEventHandler]:
        if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE):
            raise SystemExit()
        elif event.sym == tcod.event.K_c:
            # TODO: Load the game here
            pass
        elif event.sym == tcod.event.K_n:
            return input_handlers.MainGameEventHandler(new_game())

        return None

Let’s break this code down a bit.

background_image = tcod.image.load("menu_background.png")[:, :, :3]

This line loads the image file we’ll use for our background in the main menu. If you haven’t already, be sure to download that file. You can find it here, or download it by right-clicking and saving it from here:

Main Menu Background Image

Save the file to your project directory and you should be good to go.

def new_game() -> Engine:
    """Return a brand new game session as an Engine instance."""
    map_width = 80
    map_height = 43

    room_max_size = 10
    room_min_size = 6
    max_rooms = 30

    max_monsters_per_room = 2
    max_items_per_room = 2

    player = copy.deepcopy(entity_factories.player)

    engine = Engine(player=player)

    engine.game_map = generate_dungeon(
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        max_items_per_room=max_items_per_room,
        engine=engine,
    )
    engine.update_fov()

    engine.message_log.add_message(
        "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
    )
    return engine

This should all look very familiar: it’s the same code we used to initialize our engine in main.py. We initialize the same things here, but return the Engine, so that main.py can make use of it. This will help reduce the amount of code in main.py while also making sure that we don’t waste time initializing the engine class if we’re loading from a saved file.

class MainMenu(input_handlers.BaseEventHandler):
    """Handle the main menu rendering and input."""

    def on_render(self, console: tcod.Console) -> None:
        """Render the main menu on a background image."""
        console.draw_semigraphics(background_image, 0, 0)

        console.print(
            console.width // 2,
            console.height // 2 - 4,
            "TOMBS OF THE ANCIENT KINGS",
            fg=color.menu_title,
            alignment=tcod.CENTER,
        )
        console.print(
            console.width // 2,
            console.height - 2,
            "By (Your name here)",
            fg=color.menu_title,
            alignment=tcod.CENTER,
        )

        menu_width = 24
        for i, text in enumerate(
            ["[N] Play a new game", "[C] Continue last game", "[Q] Quit"]
        ):
            console.print(
                console.width // 2,
                console.height // 2 - 2 + i,
                text.ljust(menu_width),
                fg=color.menu_text,
                bg=color.black,
                alignment=tcod.CENTER,
                bg_blend=tcod.BKGND_ALPHA(64),
            )

    def ev_keydown(
        self, event: tcod.event.KeyDown
    ) -> Optional[input_handlers.BaseEventHandler]:
        if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE):
            raise SystemExit()
        elif event.sym == tcod.event.K_c:
            # TODO: Load the game here
            pass
        elif event.sym == tcod.event.K_n:
            return input_handlers.MainGameEventHandler(new_game())

        return None

It might seem strange to put an event handler here rather than in its normal spot (input_handlers.py), but this makes sense for two reasons:

  1. The main menu is specific to the start of the game.
  2. It won’t be called during the normal course of the game, like the other input handlers in that file.

Anyway, the class renders the image we specified earlier, and it displays a title, "TOMBS OF THE ANCIENT KINGS". Of course, you can change this to whatever name you have in mind for your game. It also includes a "By (Your name here)" section, so be sure to fill your name in and let everyone know who it was that worked so hard to make this game!

The menu also gives three choices: new game, continue last game, and quit. The ev_keydown method, as you might expect, handles these inputs.

  • If the player presses “Q”, the game just exits.
  • If the player presses “N”, a new game starts. We do this by returing the MainGameEventHandler, and calling the new_game function to create our new engine.
  • If the player presses “C”, theoretically, a saved game should load. However, we haven’t gotten there yet, so as of now, nothing happens.

Let’s utilize our MainMenu function in main.py, like this:

#!/usr/bin/env python3
-import copy
import traceback

import tcod

import color
-from engine import Engine
-import entity_factories
import exceptions
import input_handlers
-from procgen import generate_dungeon
+import setup_game


def main() -> None:
    screen_width = 80
    screen_height = 50

-   map_width = 80
-   map_height = 43

-   room_max_size = 10
-   room_min_size = 6
-   max_rooms = 30

-   max_monsters_per_room = 2
-   max_items_per_room = 2

    tileset = tcod.tileset.load_tilesheet(
        "dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD
    )

-   player = copy.deepcopy(entity_factories.player)

-   engine = Engine(player=player)

-   engine.game_map = generate_dungeon(
-       max_rooms=max_rooms,
-       room_min_size=room_min_size,
-       room_max_size=room_max_size,
-       map_width=map_width,
-       map_height=map_height,
-       max_monsters_per_room=max_monsters_per_room,
-       max_items_per_room=max_items_per_room,
-       engine=engine,
-   )
-   engine.update_fov()

-   engine.message_log.add_message(
-       "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
-   )

-   handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine)
+   handler: input_handlers.BaseEventHandler = setup_game.MainMenu()

    with tcod.context.new_terminal(
        ...
#!/usr/bin/env python3
import copy
import traceback

import tcod

import color
from engine import Engine
import entity_factories
import exceptions
import input_handlers
from procgen import generate_dungeon
import setup_game


def main() -> None:
    screen_width = 80
    screen_height = 50

    map_width = 80
    map_height = 43

    room_max_size = 10
    room_min_size = 6
    max_rooms = 30

    max_monsters_per_room = 2
    max_items_per_room = 2

    tileset = tcod.tileset.load_tilesheet(
        "dejavu10x10_gs_tc.png", 32, 8, tcod.tileset.CHARMAP_TCOD
    )

    player = copy.deepcopy(entity_factories.player)

    engine = Engine(player=player)

    engine.game_map = generate_dungeon(
        max_rooms=max_rooms,
        room_min_size=room_min_size,
        room_max_size=room_max_size,
        map_width=map_width,
        map_height=map_height,
        max_monsters_per_room=max_monsters_per_room,
        max_items_per_room=max_items_per_room,
        engine=engine,
    )
    engine.update_fov()

    engine.message_log.add_message(
        "Hello and welcome, adventurer, to yet another dungeon!", color.welcome_text
    )

    handler: input_handlers.BaseEventHandler = input_handlers.MainGameEventHandler(engine)
    handler: input_handlers.BaseEventHandler = setup_game.MainMenu()

    with tcod.context.new_terminal(
        ...

We’re removing the code that dealt with setting up the engine, as that’s been moved into the new_game function. All we have to do here is set our handler to MainMenu, and the MainMenu class handles the rest from there.

Run the game now, and you should see the main menu!

Part 10 - Main Menu

*Note: If you run the project and get this error: Process finished with exit code 139 (interrupted by signal 11: SIGSEGV), it means you didn’t download the menu image file.

At last, we’ve come to the part where we’ll write the function that will save our game! This will be a method in Engine, and we’ll write it like this:

from __future__ import annotations

+import lzma
+import pickle
from typing import TYPE_CHECKING
...

class Engine:
    ...

+   def save_as(self, filename: str) -> None:
+       """Save this Engine instance as a compressed file."""
+       save_data = lzma.compress(pickle.dumps(self))
+       with open(filename, "wb") as f:
+           f.write(save_data)
from __future__ import annotations

import lzma
import pickle
from typing import TYPE_CHECKING
...

class Engine:
    ...

    def save_as(self, filename: str) -> None:
        """Save this Engine instance as a compressed file."""
        save_data = lzma.compress(pickle.dumps(self))
        with open(filename, "wb") as f:
            f.write(save_data)

pickle.dumps serializes an object hierarchy in Python. lzma.compress compresses the data, so it takes up less space. We then use with open(filename, "wb") as f: to write the file (wb means “write in binary mode”), calling f.write(save_data) to write the data.

It might be hard to believe, but this is all we need to save our game! Because all of the things we need to save exist in the Engine class, we can pickle it, and we’re done!

Of course, it’s not quite that simple. We still need to call this method, and handle a few edge cases, like when the user tries to load a save file that doesn’t exist.

To save our game, we’ll call save_as from our main.py function. We’ll set up another function called save_game to call it, like this:

...
import color
import exceptions
import setup_game
import input_handlers


+def save_game(handler: input_handlers.BaseEventHandler, filename: str) -> None:
+   """If the current event handler has an active Engine then save it."""
+   if isinstance(handler, input_handlers.EventHandler):
+       handler.engine.save_as(filename)
+       print("Game saved.")


def main() -> None:
    ...

        ...
        except exceptions.QuitWithoutSaving:
            raise
        except SystemExit:  # Save and quit.
-           # TODO: Add the save function here
+           save_game(handler, "savegame.sav")
            raise
        except BaseException:  # Save on any other unexpected exception.
-           # TODO: Add the save function here
+           save_game(handler, "savegame.sav")
            raise


if __name__ == "__main__":
    main()
...
import color
import exceptions
import setup_game
import input_handlers


def save_game(handler: input_handlers.BaseEventHandler, filename: str) -> None:
    """If the current event handler has an active Engine then save it."""
    if isinstance(handler, input_handlers.EventHandler):
        handler.engine.save_as(filename)
        print("Game saved.")


def main() -> None:
    ...

        ...
        except exceptions.QuitWithoutSaving:
            raise
        except SystemExit:  # Save and quit.
            # TODO: Add the save function here
            save_game(handler, "savegame.sav")
            raise
        except BaseException:  # Save on any other unexpected exception.
            # TODO: Add the save function here
            save_game(handler, "savegame.sav")
            raise


if __name__ == "__main__":
    main()

Now when you exit the game, you should see a new savegame.sav in your project directory.

One thing that would help to handle the case where the user tries to load a saved file when one doesn’t exist would be a pop-up message. This message will appear in the center of the screen, and disappear after any key is pressed.

Add this class to input_handlers.py:

class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]):
    ...


+class PopupMessage(BaseEventHandler):
+   """Display a popup text window."""

+   def __init__(self, parent_handler: BaseEventHandler, text: str):
+       self.parent = parent_handler
+       self.text = text

+   def on_render(self, console: tcod.Console) -> None:
+       """Render the parent and dim the result, then print the message on top."""
+       self.parent.on_render(console)
+       console.tiles_rgb["fg"] //= 8
+       console.tiles_rgb["bg"] //= 8

+       console.print(
+           console.width // 2,
+           console.height // 2,
+           self.text,
+           fg=color.white,
+           bg=color.black,
+           alignment=tcod.CENTER,
+       )

+   def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]:
+       """Any key returns to the parent handler."""
+       return self.parent


class EventHandler(BaseEventHandler):
    ...
class BaseEventHandler(tcod.event.EventDispatch[ActionOrHandler]):
    ...


class PopupMessage(BaseEventHandler):
    """Display a popup text window."""

    def __init__(self, parent_handler: BaseEventHandler, text: str):
        self.parent = parent_handler
        self.text = text

    def on_render(self, console: tcod.Console) -> None:
        """Render the parent and dim the result, then print the message on top."""
        self.parent.on_render(console)
        console.tiles_rgb["fg"] //= 8
        console.tiles_rgb["bg"] //= 8

        console.print(
            console.width // 2,
            console.height // 2,
            self.text,
            fg=color.white,
            bg=color.black,
            alignment=tcod.CENTER,
        )

    def ev_keydown(self, event: tcod.event.KeyDown) -> Optional[BaseEventHandler]:
        """Any key returns to the parent handler."""
        return self.parent


class EventHandler(BaseEventHandler):
    ...

This displays a message on top of the current display, whether it’s the main menu or the main game. When the player presses a key (any key), the message disappears.

Now let’s shift our focus to loading the game. We can add a load_game function in our setup_game.py file, which will attempt to load the game. We’ll call it when we press the “c” key on the main menu. Open up setup_game.py and edit it like this:

from __future__ import annotations

import copy
+import lzma
+import pickle
+import traceback
from typing import Optional

import tcod
...


def new_game() -> Engine:
    ...


+def load_game(filename: str) -> Engine:
+   """Load an Engine instance from a file."""
+   with open(filename, "rb") as f:
+       engine = pickle.loads(lzma.decompress(f.read()))
+   assert isinstance(engine, Engine)
+   return engine


class MainMenu(input_handlers.BaseEventHandler):
    ...

    def ev_keydown(
        self, event: tcod.event.KeyDown
    ) -> Optional[input_handlers.BaseEventHandler]:
        if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE):
            raise SystemExit()
        elif event.sym == tcod.event.K_c:
-           # TODO: Load the game here
-           pass
+           try:
+               return input_handlers.MainGameEventHandler(load_game("savegame.sav"))
+           except FileNotFoundError:
+               return input_handlers.PopupMessage(self, "No saved game to load.")
+           except Exception as exc:
+               traceback.print_exc()  # Print to stderr.
+               return input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}")
        elif event.sym == tcod.event.K_n:
            return input_handlers.MainGameEventHandler(new_game())

        return None
from __future__ import annotations

import copy
import lzma
import pickle
import traceback
from typing import Optional

import tcod
...


def new_game() -> Engine:
    ...


def load_game(filename: str) -> Engine:
    """Load an Engine instance from a file."""
    with open(filename, "rb") as f:
        engine = pickle.loads(lzma.decompress(f.read()))
    assert isinstance(engine, Engine)
    return engine


class MainMenu(input_handlers.BaseEventHandler):
    ...

    def ev_keydown(
        self, event: tcod.event.KeyDown
    ) -> Optional[input_handlers.BaseEventHandler]:
        if event.sym in (tcod.event.K_q, tcod.event.K_ESCAPE):
            raise SystemExit()
        elif event.sym == tcod.event.K_c:
            # TODO: Load the game here
            pass
            try:
                return input_handlers.MainGameEventHandler(load_game("savegame.sav"))
            except FileNotFoundError:
                return input_handlers.PopupMessage(self, "No saved game to load.")
            except Exception as exc:
                traceback.print_exc()  # Print to stderr.
                return input_handlers.PopupMessage(self, f"Failed to load save:\n{exc}")
        elif event.sym == tcod.event.K_n:
            return input_handlers.MainGameEventHandler(new_game())

        return None

load_game essentially works the opposite of save_as, by opening up the file, uncompressing and unpickling it, and returning the instance of the Engine class. It then passes that engine to MainGameEventHandler. If no save game exists, or an error occured, we display a popup message.

And with that change, we can load our game! Try exiting your game and loading it afterwards.

The implementation as it exists now does have one major issue though: the player can load their save file after dying, and doing so actually allows the player to take an extra turn! The player can’t continue the game after that though, as our game immediately after detects that the player is dead, and the game state reverts to a game over. Still, this is an odd little bug that can be fixed quite simply: by deleting the save game file after the player dies.

To do that, we can override the ev_quit method in GameOverEventHandler. Open up input_handlers.py and make the following fix:

from __future__ import annotations

+import os

from typing import Callable, Optional, Tuple, TYPE_CHECKING, Union
...


class GameOverEventHandler(EventHandler):
+   def on_quit(self) -> None:
+       """Handle exiting out of a finished game."""
+       if os.path.exists("savegame.sav"):
+           os.remove("savegame.sav")  # Deletes the active save file.
+       raise exceptions.QuitWithoutSaving()  # Avoid saving a finished game.

+   def ev_quit(self, event: tcod.event.Quit) -> None:
+       self.on_quit()

    def ev_keydown(self, event: tcod.event.KeyDown) -> None:
        if event.sym == tcod.event.K_ESCAPE:
-           raise SystemExit()
+           self.on_quit()
from __future__ import annotations

import os

from typing import Callable, Optional, Tuple, TYPE_CHECKING, Union
...


class GameOverEventHandler(EventHandler):
    def on_quit(self) -> None:
        """Handle exiting out of a finished game."""
        if os.path.exists("savegame.sav"):
            os.remove("savegame.sav")  # Deletes the active save file.
        raise exceptions.QuitWithoutSaving()  # Avoid saving a finished game.

    def ev_quit(self, event: tcod.event.Quit) -> None:
        self.on_quit()

    def ev_keydown(self, event: tcod.event.KeyDown) -> None:
        if event.sym == tcod.event.K_ESCAPE:
            raise SystemExit()
            self.on_quit()

We use the os module to find the save file, and if it exists, we remove it. We then raise QuitWithoutSaving, so that the game won’t be saved on exiting. Now when the player meets his or her tragic end (it’s a roguelike, it’s inevitable!), the save file will be deleted.

Last thing before we wrap up: We’re creating the .sav files to represent our saved games, but we don’t want to include these in our Git repository, since that should be reserved for just the code. The fix for this is to add this to our .gitignore file:

+# Saved games
+*.sav
# Saved games
*.sav

The rest of the .gitignore is omitted, as your .gitignore file may look different from mine. It doesn’t matter where you add this in.

If you want to see the code so far in its entirety, click here.

Click here to move on to the next part of this tutorial.