
A game agent should own one responsibility: choosing an action from the information it is allowed to observe. It should not draw sprites, read keyboard events, or update Pygame surfaces. Those are separate jobs. Keeping the agent focused makes its behavior easier to test and lets the same decision logic run in a simulation, a replay, or the actual game.
Give the agent an observation and require it to return an action. Make both values explicit. An observation is a snapshot of relevant facts, not a reference to the whole game world. An action is a request, not a command to mutate that world.
from dataclasses import dataclass
from enum import Enum, auto
class Action(Enum):
WAIT = auto()
MOVE_LEFT = auto()
MOVE_RIGHT = auto()
JUMP = auto()
@dataclass(frozen=True)
class Observation:
player_x: float
player_y: float
target_x: float
target_y: float
grounded: bool
class Agent:
def choose_action(self, observation: Observation) -> Action:
raise NotImplementedError
The game loop gathers the facts, asks the agent for a decision, then applies that decision. Rendering can display the resulting state, but it should not influence how the decision is made.
observation = Observation(
player_x=player.x,
player_y=player.y,
target_x=target.x,
target_y=target.y,
grounded=player.grounded,
)
action = agent.choose_action(observation)
world.apply_action(player, action)
renderer.draw(screen, world)
This boundary prevents a common source of accidental complexity: an agent that reaches into sprites, groups, clocks, and event queues whenever it needs one fact. Such an agent is coupled to the presentation framework and difficult to exercise without starting the game. Pass only the data needed for a decision; when the agent needs another fact, make that dependency visible in the observation.
Keep persistent agent state separate from the current observation. The observation describes what is true now. Agent state records what the agent remembers, such as its current goal or how long it has been waiting. That distinction matters when decisions depend on history.
@dataclass
class AgentMemory:
last_action: Action = Action.WAIT
stuck_frames: int = 0
class PursuitAgent(Agent):
def __init__(self):
self.memory = AgentMemory()
def choose_action(self, observation: Observation) -> Action:
distance = observation.target_x - observation.player_x
if abs(distance) < 4:
action = Action.WAIT
elif distance < 0:
action = Action.MOVE_LEFT
else:
action = Action.MOVE_RIGHT
self.memory.last_action = action
return action
Do not let memory become a hidden copy of the game. Store only information that cannot be derived from the current observation, and define when that memory is reset. A newly spawned agent, a restarted round, and a loaded replay should each have an intentional memory policy. Otherwise, an old decision can leak into a new episode.
Different agents can implement the same contract. The game loop does not need to know whether a decision came from a simple rule, a search procedure, or a learned policy. It only needs a valid action, and the agent only needs a well-formed observation.
def run_turn(agent: Agent, world) -> None:
observation = world.observe_for(agent)
action = agent.choose_action(observation)
if not isinstance(action, Action):
raise TypeError("Agent returned an invalid action")
world.apply_action(agent, action)
Amazon eGift Card | Any Occasion
(as of October 26, 2026 04:11 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Separating decision logic from Pygame rendering
The agent module should not import Pygame. If decision code imports pygame, the dependency has crossed the boundary: a test now needs a graphics library merely to ask what the agent would do. Keep framework-specific work in the outer game loop, and translate its results into plain values before calling the agent.
def build_observation(player, target) -> Observation:
return Observation(
player_x=float(player.rect.centerx),
player_y=float(player.rect.centery),
target_x=float(target.rect.centerx),
target_y=float(target.rect.centery),
grounded=bool(player.grounded),
)
def apply_action(player, action: Action) -> None:
if action is Action.MOVE_LEFT:
player.set_horizontal_input(-1)
elif action is Action.MOVE_RIGHT:
player.set_horizontal_input(1)
else:
player.set_horizontal_input(0)
if action is Action.JUMP and player.grounded:
player.jump()
The adapter translates between two vocabularies. The agent speaks in game concepts such as MOVE_LEFT; the physics model speaks in velocity, collision, and jump impulses. Keeping that translation explicit prevents a policy from becoming entangled with sprite methods or physics implementation details.
Rendering is another adapter. It reads the world state and draws it; it does not decide what the world should do. Avoid placing calls to choose_action inside drawing methods, or changing game state as a side effect of drawing. A renderer may be called more than once for a state, or not at all during a headless simulation.
class Renderer:
def draw(self, screen, world) -> None:
screen.fill((24, 28, 36))
for entity in world.entities:
pygame.draw.rect(
screen,
entity.color,
entity.rect,
)
pygame.display.flip()
Let the game loop coordinate events, decisions, simulation, and presentation. Human input can produce the same action type as an agent, so the world does not need a separate movement path for each controller.
def read_human_action(events) -> Action:
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
return Action.MOVE_LEFT
if keys[pygame.K_RIGHT]:
return Action.MOVE_RIGHT
if keys[pygame.K_SPACE]:
return Action.JUMP
return Action.WAIT
while running:
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
running = False
observation = build_observation(player, target)
action = agent.choose_action(observation)
apply_action(player, action)
world.update(clock.get_time() / 1000.0)
renderer.draw(screen, world)
clock.tick(60)
The order of these operations is part of the game’s behavior. If the agent observes before the world updates, it acts on the previous state; if it observes afterward, it acts on the new state. Choose one schedule and keep it consistent in live play, tests, and replay. Pass elapsed time into the simulation rather than letting the agent read the Pygame clock, so the same observation and agent state can be evaluated without a running window.
A headless test can then exercise the decision boundary without creating a display, loading images, or pumping an event queue.
def test_pursuit_agent_moves_toward_target():
agent = PursuitAgent()
observation = Observation(
player_x=20.0,
player_y=10.0,
target_x=80.0,
target_y=10.0,
grounded=True,
)
action = agent.choose_action(observation)
assert action is Action.MOVE_RIGHT
Building behavior from small, testable rules
Once the boundary is clear, build behavior from rules that each answer one narrow question. A rule should inspect an observation and either propose an action or decline to decide. It should not read global state, modify the world, or quietly change the agent’s memory. Those constraints make the rule deterministic: the same input produces the same result.
from typing import Callable, Optional
Rule = Callable[[Observation], Optional[Action]]
def jump_toward_elevated_target(
observation: Observation,
) -> Optional[Action]:
target_is_above = observation.target_y < observation.player_y - 12
if observation.grounded and target_is_above:
return Action.JUMP
return None
def move_toward_target(
observation: Observation,
) -> Optional[Action]:
distance = observation.target_x - observation.player_x
if distance < -4:
return Action.MOVE_LEFT
if distance > 4:
return Action.MOVE_RIGHT
return Action.WAIT
The jump rule does not decide horizontal movement. The movement rule does not decide whether jumping is possible. That separation exposes the boundaries of each behavior and lets a test name the exact decision it is checking. The four-pixel dead zone also prevents small position changes from making the agent alternate between left and right.
Combine rules with an explicit priority order. A rule that returns None has no opinion; the first rule that returns an action wins. Here, jumping takes precedence over horizontal movement, while the final rule guarantees that the agent always returns a valid action.
class RuleBasedAgent(Agent):
def __init__(self):
self.rules: tuple[Rule, ...] = (
jump_toward_elevated_target,
move_toward_target,
)
def choose_action(self, observation: Observation) -> Action:
for rule in self.rules:
action = rule(observation)
if action is not None:
return action
return Action.WAIT
Priority is behavior, not an implementation detail. If the order changes, the agent may behave differently even though every individual rule remains unchanged. Keep the order visible, and test cases where more than one rule could apply.
def test_jump_rule_requires_grounded_player():
observation = Observation(
player_x=20.0,
player_y=80.0,
target_x=60.0,
target_y=20.0,
grounded=False,
)
assert jump_toward_elevated_target(observation) is None
def test_jump_rule_acts_when_target_is_above():
observation = Observation(
player_x=20.0,
player_y=80.0,
target_x=60.0,
target_y=20.0,
grounded=True,
)
assert jump_toward_elevated_target(observation) is Action.JUMP
Test the movement rule at both sides of its dead zone, including the exact boundary. Boundary tests catch accidental changes from < to <=, which can alter behavior frame after frame.
def test_movement_rule_points_left_outside_dead_zone():
observation = Observation(
player_x=40.0,
player_y=20.0,
target_x=35.0,
target_y=20.0,
grounded=True,
)
assert move_toward_target(observation) is Action.MOVE_LEFT
def test_movement_rule_waits_inside_dead_zone():
observation = Observation(
player_x=40.0,
player_y=20.0,
target_x=44.0,
target_y=20.0,
grounded=True,
)
assert move_toward_target(observation) is Action.WAIT
Test composition separately from the rules. This verifies that a higher-priority decision is not lost when another rule could also propose an action.
def test_jump_has_priority_over_horizontal_movement():
agent = RuleBasedAgent()
observation = Observation(
player_x=20.0,
player_y=80.0,
target_x=60.0,
target_y=20.0,
grounded=True,
)
assert agent.choose_action(observation) is Action.JUMP
When a rule becomes difficult to test, it usually combines several decisions or depends on hidden state. Split it at the decision boundary. For example, computing whether a target is above the player can be a small predicate, while choosing whether that fact warrants a jump remains a separate rule. Keep the rule inputs explicit, and pass any needed threshold as a parameter rather than burying it in a global constant.
def target_is_above(
observation: Observation,
margin: float,
) -> bool:
return observation.target_y < observation.player_y - margin
def jump_if_grounded_and_target_is_above(
observation: Observation,
margin: float = 12.0,
) -> Optional[Action]:
if observation.grounded and target_is_above(observation, margin):
return Action.JUMP
return None
Improving intelligence through measurable feedback
An agent that appears clever in one playthrough may simply have received an easy sequence of states. Measure behavior across many episodes, and use the same initial conditions when comparing changes. A score alone is rarely enough: record whether the goal was reached, how long it took, how often the agent collided, and whether it became stuck.
from dataclasses import dataclass
@dataclass(frozen=True)
class EpisodeResult:
seed: int
succeeded: bool
steps: int
collisions: int
reward: float
Keep the evaluation loop independent of Pygame. The environment should expose a deterministic reset and step interface, while the agent receives only observations and returns actions. Recreate the agent for each episode so memory from one run cannot affect another.
def run_episode(agent_factory, environment_factory, seed, max_steps):
environment = environment_factory()
observation = environment.reset(seed=seed)
agent = agent_factory()
collisions = 0
reward = 0.0
for step in range(max_steps):
action = agent.choose_action(observation)
transition = environment.step(action)
observation = transition.observation
reward += transition.reward
collisions += int(transition.collided)
if transition.done:
return EpisodeResult(
seed=seed,
succeeded=transition.goal_reached,
steps=step + 1,
collisions=collisions,
reward=reward,
)
return EpisodeResult(
seed=seed,
succeeded=False,
steps=max_steps,
collisions=collisions,
reward=reward,
)
Aggregate results instead of judging individual runs. Success rate shows whether the agent completes the task; median steps shows how quickly it does so without letting a few unusually long episodes dominate the average. Keep collision counts and rewards visible as separate measures, since improving one number can conceal a regression in another.
def evaluate(agent_factory, environment_factory, seeds, max_steps):
results = [
run_episode(agent_factory, environment_factory, seed, max_steps)
for seed in seeds
]
successes = sum(result.succeeded for result in results)
success_rate = successes / len(results)
successful_steps = sorted(
result.steps
for result in results
if result.succeeded
)
if successful_steps:
middle = len(successful_steps) // 2
median_steps = successful_steps[middle]
else:
median_steps = None
mean_collisions = sum(
result.collisions for result in results
) / len(results)
return {
"episodes": len(results),
"success_rate": success_rate,
"median_successful_steps": median_steps,
"mean_collisions": mean_collisions,
}
Use a fixed seed set to compare a candidate against a known baseline. Each seed should produce the same initial world for both agents. This makes a change in the result attributable to the policy more often than to a different random layout.
seeds = range(100, 200)
baseline = evaluate(
baseline_agent_factory,
environment_factory,
seeds,
max_steps=600,
)
candidate = evaluate(
candidate_agent_factory,
environment_factory,
seeds,
max_steps=600,
)
assert candidate["success_rate"] >= baseline["success_rate"]
A single aggregate can still hide a weak category of situations. Group results by relevant conditions, such as map type, target distance, or whether the player starts airborne. A policy that succeeds on easy maps and fails consistently on narrow platforms should not be described as improved merely because its overall average rose.
def success_rate_by_map(results):
totals = {}
successes = {}
for result in results:
map_type = result.map_type
totals[map_type] = totals.get(map_type, 0) + 1
successes[map_type] = (
successes.get(map_type, 0) + int(result.succeeded)
)
return {
map_type: successes[map_type] / count
for map_type, count in totals.items()
}
Record enough information to reproduce failures, but do not log every frame by default. A compact trace can include the seed, step number, observation, chosen action, and resulting event. When an episode fails, replay that seed and inspect the trace around the first unexpected decision.
@dataclass(frozen=True)
class DecisionRecord:
seed: int
step: int
observation: Observation
action: Action
event: str
def record_decision(log, seed, step, observation, action, event):
log.append(
DecisionRecord(
seed=seed,
step=step,
observation=observation,
action=action,
event=event,
)
)
