もっと こだわり

Choking on My Own Dog Food

Posted

Code is rarely done. You find gaps, requirements change, and you adjust. Refactoring is part of the job.

I have been eating my own dog food - specifically, using my customisable control system in my latest game for one game a month challenge - and realised it is missing an important ingredient.

Square Meal

This system maps input events (keys, buttons) to action names like “jump” or “move left.” It’s simple and lets you reconfigure controls without changing game code.

Here is an example of mapping input events to actions:

get_actions = controls.bind({
    "jump": [pg.Event(pg.KEYDOWN, key=pg.K_SPACE)],
    "move_left": [pg.Event(pg.KEYDOWN, key=pg.K_LEFT)],
    "move_right": [pg.Event(pg.KEYDOWN, key=pg.K_RIGHT)],
})

In the game loop, we check for these actions like this:

for action in get_actions(pg.event.get()):
    if action == "jump":
        player.jump()
    if action == "move_left":
        player.move_left()
    if action == "move_right":
        player.move_right()

This separates the details of input handling from the game logic, making it easy to change the controls.

Taste for More

In my new game, Eterniski, I want steering to feel natural: small tilts for gentle turns, full tilt for fast carves. That needs analog input, not just on/off.

Pygame generates JOYAXISMOTION events whenever the stick moves. These contain the axis index (e.g. 0 for horizontal, 1 for vertical) and a value representing tilt, which ranges from -1.0 (full tilt left or up) to 1.0 (full tilt right or down).1

The current bind returns only action strings, so metadata like joystick tilt (event.value) or mouse position (event.pos) is lost.

Refining the Recipe

It was obvious that I needed to refactor the control system to supply the event metadata alongside the action name.

My first thought was KISS: stick to built-in types and return a list of tuples, where each tuple contains the action string and the event object that triggered it.

But a clearer, more type-safe approach would be to define an Action class which has a name (e.g. "move right") and blends in the event metadata, like joystick tilt.

A custom class makes intent clear in type hints, e.g. map_events_to_actions(events: list[Event]) -> list[Action].

Copying the event metadata into the Action class integrates seamlessly with the match statement, as it was made for exactly this kind of situation. Class patterns let you match on the action name and pull out event metadata in one go, which makes the code easy to read.

For a small tidy-up, we can also define __match_args__ on the class to set name as the first positional argument, letting us skip typing name= in the class pattern.

Here is the updated control system module with the new Action class:

from collections.abc import Callable
from types import SimpleNamespace
from typing import Any

import pygame as pg


class Action(SimpleNamespace):
    __match_args__ = ("name",)

    def __init__(self, name: str, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.name = name


ActionMapper = Callable[[list[pg.Event]], list[Action]]


def bind(mapping: dict[str, list[pg.Event]]) -> ActionMapper:

    def get_action(event: pg.Event) -> str | None:

        def match_attribute(attr_name: str, value: Any) -> bool:
            if callable(value):
                return value(getattr(event, attr_name))
            return getattr(event, attr_name) == value

        for action, template_events in mapping.items():
            if any(
                event.type == template_event.type
                and all(
                    match_attribute(attr_name, value)
                    for attr_name, value in template_event.__dict__.items()
                )
                for template_event in template_events
            ):
                return action
        return None

    def map_events_to_actions(events: list[pg.Event]) -> list[Action]:
        return [
            Action(action, event.__dict__)
            for event in events
            if (action := get_action(event))
        ]

    return map_events_to_actions

With this change in place, we can define our joystick bindings like this:

get_actions = controls.bind({
    "move_left": [pg.Event(pg.JOYAXISMOTION, axis=0, value=lambda v: v < -0.1)],
    "move_right": [pg.Event(pg.JOYAXISMOTION, axis=0, value=lambda v: v > 0.1)],
    "jump": [pg.Event(pg.JOYBUTTONDOWN, button=0)],
})

And we can update the game loop code and use pattern matching to handle the actions:

for action in get_actions(pg.event.get()):
    match action:
        case Action("move_left", value=value):
            player.turn_left(value)
        case Action("move_right", value=value):
            player.turn_right(value)
        case Action("jump"):
            player.jump()

The game can read tilt values directly, so turning is smooth and responsive. Discrete and analog inputs both work, and the code is clear.

Chef's Kiss

The first bind worked, until using it in a new game exposed its limits. Carrying event metadata with the action enables both discrete and analog controls.

Good code adapts.


  1. I'm omitting the instance_id attribute, which specifies which joystick the event came from, for simplicity. In a real game, you would want to check this to be able to handle multiple joysticks. There is also a joy attribute which did the same thing before Pygame 2.0.0, but is now deprecated.