Overlay States

From version 2.5 onwards the library supports overlay states. In this guide we’ll take a look at what overlay states are, how to open and close them, and how they interact with the rest of the state manager.

What is an Overlay State?

So far, switching screens with StateManager.change_state fully replaces whatever was previously running. This works well for full screen transitions like MainMenu to Game, but it falls apart for things like a pause menu or a settings panel - screens that need to be drawn on top of whatever is currently running, without losing that state’s progress.

This is exactly what overlay states solve. Opening an overlay pushes a new state on top of the currently running one without disturbing it. Closing the overlay simply removes it, and whatever was running underneath picks back up right where it left off.

A mock example to set the stage-

from game_state import State, StateManager


class Game(State):
    def on_enter(self, previous_state) -> None:
        print("Entered Game.")


class PauseMenu(State):
    def on_overlay_open(self, temporary: bool) -> None:
        print("Game paused.")

    def on_overlay_close(self, temporary: bool) -> None:
        print("Game resumed.")


manager = StateManager(...)  # ... is a placeholder
manager.load_states(Game, PauseMenu)
manager.change_state("Game")

overlay_id = manager.open_overlay("PauseMenu")
# manager.current_state is now PauseMenu.

manager.close_overlay(overlay_id)
# manager.current_state is Game again, exactly as it was.

Running this gives us-

Entered Game.
Game paused.
Game resumed.

Notice that Game.on_enter is only called once. Opening and closing the overlay never touches Game at all — it’s simply hidden underneath while the overlay is active.

Opening an Overlay

Overlays are opened with StateManager.open_overlay, passing in the state_name of the state you want to use as an overlay.

overlay_id = manager.open_overlay("PauseMenu")

This returns a state_id which you’ll need later to close that specific overlay. If you don’t supply one yourself, the manager assigns an incrementing integer automatically.

You can also assign your own ID-

manager.open_overlay("PauseMenu", "pause")

Note

It’s recommended to use a string rather than an integer when assigning a custom ID. When no ID is supplied, the manager defaults to an incrementing integer counter for its overlay elements, which can conflict with custom integer IDs.

Only the newly opened overlay’s State.on_overlay_open listener is called when you open an overlay — no other global or state listeners run.

Closing an Overlay

Overlays are closed with StateManager.close_overlay, using the same state_id that was returned (or supplied) when it was opened.

manager.close_overlay("pause")

Unlike change_state, you’re not restricted to closing only the topmost overlay, any opened overlay can be closed directly by its ID, regardless of where it sits in the stack.

If you’d rather close everything at once, StateManager.close_all_overlays clears every opened overlay in one call, most recently opened first-

manager.close_all_overlays()

Just like opening, closing an overlay only calls that state’s State.on_overlay_close listener. No other global or state listeners run.

Stacking Multiple Overlays

You’re not limited to a single overlay at a time, nor to only one instance of a given state. Opening the same state twice creates a fresh instance for the second one rather than reusing the first-

first_id = manager.open_overlay("Notification", "toast-1")
second_id = manager.open_overlay("Notification", "toast-2")
# Two independent Notification instances are now stacked.

Each State instance carries a state_id attribute so it knows which overlay slot it belongs to, and a temporary flag is passed to on_overlay_open / on_overlay_close to let you tell the first instance of a state apart from any duplicates opened after it-

class Notification(State):
    def on_overlay_open(self, temporary: bool) -> None:
        if temporary:
            print("Another notification stacked on top.")
        else:
            print("First notification opened.")

Note

If the overlay you’re opening was loaded with state_args, any duplicate instances created for stacking reuse those same arguments automatically.

Overlays and change_state

By default, calling StateManager.change_state while an overlay is active only swaps out the base state underneath — any open overlays are left exactly as they are, and current_state will still point to the topmost overlay.

manager.open_overlay("PauseMenu")
manager.change_state("MainMenu")
# current_state is still PauseMenu; MainMenu is now waiting underneath it.

If you’d rather clear every overlay as part of the transition, pass clear_overlays=True-

manager.change_state("MainMenu", clear_overlays=True)
# Every overlay is closed, and current_state is now MainMenu.

Putting It Together

Here’s a small pygame example that toggles a pause menu overlay with the Esc key, without losing any of the running game’s state-

import pygame
from game_state import State, StateManager
from game_state.utils import MISSING

pygame.init()
pygame.display.init()
pygame.display.set_caption("Overlay States Example")


class MyBaseState(State["MyBaseState"]):
    # Attributes we want all our states to share.
    screen: pygame.Surface = MISSING


class GameState(MyBaseState, state_name="Game"):
    def __init__(self) -> None:
        self.player_x: float = 250.0
        self.speed: int = 200

    def process_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.QUIT:
            self.manager.is_running = False

        if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
            # When the player presses ESC, the manager opens a new overlay
            # state. The current state preserves it's context when you switch
            # back and forth from an overlay state.
            # Over here, since we do not provide any `state_id` argument, the
            # state manager creates and assigns the `PauseState` a unique integer.

            self.manager.open_overlay("Pause")

    def process_update(self, dt: float) -> None:
        self.screen.fill((0, 0, 255))

        pressed = pygame.key.get_pressed()
        if pressed[pygame.K_a]:
            self.player_x -= self.speed * dt
        if pressed[pygame.K_d]:
            self.player_x += self.speed * dt

        pygame.draw.rect(self.screen, "red", (self.player_x, 100, 50, 50))


class PauseState(MyBaseState, state_name="Pause"):
    def __init__(self) -> None:
        self.frame_snap: None | pygame.Surface = None

    def process_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.QUIT:
            self.manager.is_running = False

        if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
            # Closing the overlay hands control straight back to GameState,
            # right where the player left off.
            # The state automatically gets assigned a `state_id` which we or
            # the state manager created when calling `open_overlay` method.
            self.manager.close_overlay(self.state_id)

    def on_overlay_open(self, temporary: bool) -> None:
        # This is called right when we call `StateManager.open_overlay`
        if self.frame_snap is None:
            # Create a snapshot of the last frame and darken it.
            self.frame_snap = self.screen.copy()
            self.frame_snap.fill((100, 100, 100), special_flags=pygame.BLEND_RGBA_MULT)

    def on_overlay_close(self, temporary: bool) -> None:
        # This is called when either `StateManager.close_overlay` or `StateManager.close_all_overlays` fires.
        self.frame_snap = None

    def process_update(self, dt: float) -> None:
        # Draws a green "menu" box.
        pygame.draw.rect(self.frame_snap, "green", (100, 200, 300, 200))
        self.screen.blit(self.frame_snap, (0, 0))


def main() -> None:
    screen = pygame.display.set_mode((500, 600))

    state_manager = StateManager(bound_state_type=MyBaseState, screen=screen)
    state_manager.load_states(GameState, PauseState)
    state_manager.change_state("Game")

    clock = pygame.time.Clock()

    while state_manager.is_running:
        dt = clock.tick(60) / 1000

        for event in pygame.event.get():
            state_manager.current_state.process_event(event)

        state_manager.current_state.process_update(dt)
        pygame.display.update()

if __name__ == "__main__":
    main()

When you press ESC the game pauses and shows a green “menu” box. Upon pressing ESC again, the game unpauses and resumes from where you left off. This allows you to preserve the context of your original state.

Warning

Although it’s possible to use open_overlay as a full replacement for change_state, it’s discouraged to do so if the overlays are never cleared. This can cause memory leaks if used irresponsibly.

The code is also available in it’s Github examples directory.