Design Patterns and Video Games

2D Strategy Game (7): Cache

This post adds a caching mechanism to reduce rendering time: we only repaint a layer if a cell changes. We use the Proxy and Observer patterns to get a robust design.

This post is part of the 2D Strategy Game series

Cache rendering with a proxy

We implement a Proxy pattern to save the rendering of a layer. This approach is an advanced version of what we did in the tank game (see this post, optimizations section), where we save a rendering in a surface:

Cache component

The Component abstract class defines the basics of a UI component:

We implement this abstract class for each component type. For instance, LayerComponent is a child class that renders a layer, which works as the previous one.

We also create a CacheComponent class to save the rendering of any component. This class contains a reference to a component, and its main job is to behave like this component. For instance, the dispose() method of CacheComponent calls the dispose() method of the cached component:

def dispose(self):
    self.__component.dispose()

All methods work the same, except for the render() method that saves the rendering. It is the main idea of the Proxy pattern: behave like the referenced objects, and eventually add a new feature:

def render(self, surface: Surface):
    if self.__surface is None or self.__component._needRefresh:
        self.__surface = Surface(surface.get_size(), flags=pygame.SRCALPHA)
        self.__component.render(self.__surface)
    surface.blit(self.__surface, (0, 0))

Line 2 checks if we need to render the component. There are two cases: either we have no surface or must repaint the component.

Line 3 creates a new surface with an alpha channel. We could create a surface with no alpha channel for the first layer and save even more time, as we did in the tank game. You can try to add this feature: it is a good exercise.

Line 4 renders the component in the cache surface, and line 5 blits the cache surface in the target surface.

Finally, to use the CacheComponent class, we only need to create it on top of an existing component. For instance, when we create the layer component:

layerComponent = LayerComponent(theme, world, name)

To enable caching, we only need to add CacheComponent:

layerComponent = CacheComponent(LayerComponent(theme, world, name))

The resulting layerComponent behaves like a layer component, except for the caching: its needRefresh attributes must be True to update its rendering.

Trigger rendering with an observer

The caching system works if we set the refresh attribute of a layer component to True when a cell changes. A naive approach sets this value explicitly in a command class, for instance, in the execute() method of the SetGroundValueCommand class. But unfortunately, it introduces a circular dependency: the state would depend on the UI, and the UI already depends on the state.

A good solution is to use the Observer pattern, as we did in the tank game (see this post). However, this time, we implement a more advanced solution, where we use Python generics (we only show relevant members):

Observer with Python generics

Listenable class: we create a generic class Listenable that, once implemented, provides the basic mechanisms of the Observer pattern. We use Generic from the typing package in the following way:

from typing import List, TypeVar, Generic, Iterable
IListener = TypeVar('IListener')

class Listenable(Generic[IListener]):
    def __init__(self):
        self.__listeners: List[IListener] = []
    @property
    def listeners(self) -> Iterable[IListener]:
        return self.__listeners
    def registerListener(self, listener: IListener):
        self.__listeners.append(listener)
    def removeListener(self, listener: IListener):
        self.__listeners.remove(listener)
    def __del__(self):
        for listener in self.__listeners:
            logging.warning(f"In {self.__class__.__name__}: a listener {listener.__class__.__name__} was not removed")

Note the declaration of the IListener generic variable with TypeVar from the typing package (line 2).

All the members are the ones we already saw in an Observer pattern, except for the __del__() method. Python calls this special method when it is about to destroy it. We use it to check that no more objects are listening to it. In the other case, it could lead to errors or memory leaks.

Layer class: this class becomes a child class of Listenable, where the generic variable is ILayerListener. Python replaces IListener with ILayerListener in the class above. For instance, it automatically creates a registerListener(self, listener: ILayerListener) method in the Listenable class. We only need to implement the notification methods (only one for now):

class Layer(Listenable[ILayerListener]):
    ...
    def notifyCellChanged(self, cell: Tuple[int, int]):
        for listener in self.listeners:
           listener.cellChanged(self, cell)

LayerComponent class: it listens to the Layer class:

class LayerComponent(Component, ILayerListener):
    def __init__(self, theme: Theme, world: World, name: str):
        super().__init__(theme)
        self.__tileset = theme.getTileset(name)
        self.__layer = world.getLayer(name)
        self.__layer.registerListener(self)
    def dispose(self):
        self.__layer.removeListener(self)
    def cellChanged(self, layer: Layer, cell: Tuple[int, int]):
        if layer == self.__layer:
            self._needRefresh = True

The ILayerListener class is an interface: it has no attributes (we start its name with I to remember it). Consequently, we don't need to call its constructor (no super(ILayerListener, self).__init__() in the constructor).

Line 6 let LayerComponent listen to the layer it renders. Line 8 removes it from the list of layer listeners when we plan to delete it. This step is essential: if we don't, the layer keeps a reference, and Python never destroys it (Python destroys an object when there are no more references).

When we call the cellChanged() method, it means that we changed a cell in a layer. We check that the layer is the one this component is rendering (line 10), and set needRefresh to True if it is the case (line 11). Then, during the next frame update, CacheComponent will call its render() method.

Notify cell change: in the command classes that update cells, we add a notification. For instance, in the execute() method of the SetGroundValueCommand class:

def execute(self, logic: Logic):
    ...
    ground.setValue(coords, value)
    ground.notifyCellChanged(coords)
    ...

This approach does not introduce a dependency between the state and the UI: we can delete all the UI code, the state is still functional. The team that works on the state does not have to worry about the UI and can ignore it. Thanks to the Observer pattern, they provide a mechanism that allows a connection at runtime between an event (ex: cell changes) and any class that implements ILayerListener.

Finally, a game refresh time goes from 11ms to 6ms on average on a test machine. If we force many updates, for instance, filling the world with ground tiles, the time raises to 11ms for a while, and then goes back to 6ms.

Final program

Download code and assets

In the next post, we use the Composite pattern to combine several UI components.