Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Codebase Guide - every file, what it's for, and how to use it

This is the detailed, file-by-file companion to docs/ARCHITECTURE.md (the "why" of the design) and docs/API_REFERENCE.md (a quick-lookup cheat sheet). This document is the long-form version: for every file in the project it explains what the file is responsible for, walks through every function/method it defines, and shows a working code example. If you're new to the codebase, read this one top to bottom - it's written in the order things depend on each other, so nothing is explained before the thing it relies on has already been introduced.

How to read the tables below. Each file gets a box like this:

  • Responsibility - the one job this file has.
  • Depends on - what it imports, and why.
  • Used by - what else in the project relies on it (so you can judge the blast radius before changing it).

followed by a plain-English walkthrough of every function/class it defines, and at least one runnable example.


Table of contents

  1. The big picture
  2. Project map
  3. Part 1 - Foundations: engine/utils/
  4. Part 2 - The component system itself
  5. Part 3 - GameObject
  6. Part 4 - Rendering
  7. Part 5 - Physics and collision
  8. Part 6 - Animation
  9. Part 7 - Input and movement
  10. Part 8 - The camera
  11. Part 9 - Managing the world
  12. Part 10 - Diagnostics
  13. Part 11 - The engine shell
  14. Part 12 - Game content (not engine code)
  15. Part 13 - The entry point
  16. Quick index - every function, alphabetically
  17. Cookbook - "I want to..."

The big picture

Before diving file-by-file, the one-paragraph mental model:

A GameObject is a named box that holds a list of **Component**s and nothing else. It doesn't know how to draw itself, fall under gravity, or respond to key presses - those behaviours come entirely from whichever components are attached to it. A Scene holds a list of GameObjects and drives them: every frame it calls update() on each one (which in turn calls update() on each of its components, in a fixed order), checks for trigger overlaps, and draws every SpriteRenderer it finds. An Engine owns the actual window and the loop that calls Scene.update()/Scene.render() 60 times a second. Everything else in the engine - physics, collision, animation, the camera, input handling - is just another Component that plugs into this same loop.

Engine.run()
  └─ every frame:
       ├─ Scene.update(delta_time)
       │    ├─ every active GameObject.update(delta_time)
       │    │    └─ every enabled Component.update(delta_time), in update_order
       │    └─ process trigger overlaps
       └─ Scene.render(screen)
            └─ draw every SpriteRenderer, offset by the active Camera

If you keep that diagram in your head, every file below is just "one more detail of one box in this picture."

Project map

engine/                        <- the engine itself: reusable, knows nothing about "coins" or "players"
    __init__.py                   package marker + a one-line rule ("engine/ never imports scenes/ or scripts/")
    app.py                        Engine: the window, the clock, the main loop
    game_object.py                GameObject: a named bag of components
    scene.py                      Scene: update / collide / render a set of GameObjects
    scene_manager.py              SceneManager: owns named scenes, switches between them
    debug_manager.py              DebugManager: logging + the on-screen debug overlay
    components/
        __init__.py                package marker
        component.py                Component: the base class every behaviour inherits from
        transform.py                position, rotation, scale - every GameObject has one
        sprite_renderer.py          draws a sprite at the transform's position
        rigidbody2d.py              gravity, drag, and collision response
        box_collider2d.py           the actual hitbox; solid or trigger
        animator.py                 frame-based sprite animation
        player_controller.py        extensible top-down/platformer character controller
        camera.py                   follows a target, offsets rendering
    utils/
        __init__.py                 package marker
        vector2.py                   minimal 2D vector (position/velocity/camera math)
        warnings.py                  the EngineWarning category for misconfiguration

scenes/                         <- game CONTENT: what's actually in a level
    __init__.py (none needed - see note below)
    game_scene.py                  builds the demo: a player, a floor, a coin

scripts/                        <- reusable gameplay behaviours (custom Components)
    __init__.py                    package marker
    coin_pickup.py                  example: a collectible that reacts to the player

main.py                         <- engine setup + camera init only, no game logic
assets/                         <- sprites used by the demo scene
docs/                           <- you are here

The three-way split (engine/ vs scenes/ vs scripts/) is the single most important structural idea in this project. engine/ must never contain anything that knows what a "coin" or "player" is - it only knows about generic concepts (a body with velocity, a box that can collide, a sprite that can be drawn). scenes/ describes what's actually placed in a given level. scripts/ holds gameplay behaviours that are reusable across scenes but aren't generic enough to belong in the engine (a coin pickup is specific to this game, not to 2D engines in general). When you add a new feature, ask "is this a generic engine capability, or specific to my game?" - the answer tells you which folder it belongs in.


Part 1 - Foundations: engine/utils/

Everything in this part has zero dependencies on the rest of the engine - these are the bottom of the dependency graph. Nothing here ever imports Component, GameObject, or anything from engine/components/.

engine/utils/vector2.py

  • Responsibility: A minimal 2D vector type, used anywhere the engine would otherwise have to pass around two separate x/y floats and trust every caller to keep them paired correctly.
  • Depends on: only the standard library (math).
  • Used by: Transform (position, scale), Rigidbody2D (velocity), Camera (position/offset math), Scene (render offset).

Why this exists at all

The original engine tracked position as two loose attributes (game_object.x, game_object.y) and velocity the same way (velocity_x, velocity_y). That works, but every function that moves something ends up repeating the same two-line pattern for X and for Y, and it's easy to update one and forget the other. Vector2 bundles them into one value you can add, subtract, scale, and interpolate as a unit.

What's inside

class Vector2:
    def __init__(self, x=0.0, y=0.0): ...
Member Signature What it does
copy() () -> Vector2 Returns a new, independent Vector2 with the same x/y. Use this whenever you store a reference to someone else's position and don't want later changes to it to also change yours.
as_tuple() () -> (float, float) Plain (x, y) tuple - handy for anything expecting pygame's usual coordinate format.
as_int_tuple() () -> (int, int) Same, but rounded to the nearest integer - what you want right before a screen.blit(...) call.
Vector2.zero() staticmethod () -> Vector2 Shorthand for Vector2(0, 0).
Vector2.one() staticmethod () -> Vector2 Shorthand for Vector2(1, 1) - the "no scaling" value, used as Transform.scale's default.
length() () -> float The vector's magnitude, sqrt(x² + y²) (via math.hypot, which is more numerically stable than computing it by hand).
normalized() () -> Vector2 The same direction, length 1. Returns Vector2(0, 0) for a zero-length vector instead of raising a divide-by-zero error.
lerp(other, t) (Vector2, float) -> Vector2 Linearly interpolates towards other. t is clamped to [0, 1] internally, so you can't accidentally overshoot by passing a stray 1.3. This is what Camera uses every frame to ease towards its target.

Operators you can use directly, because they're implemented as Python's special methods: a + b, a - b, -a, a * 3 (or 3 * a), a / 2, a += b, a -= b, a == b. There's no Vector2 * Vector2 (componentwise multiply) or dot product - the engine has never needed either, so they weren't added speculatively.

Example

from engine.utils.vector2 import Vector2

position = Vector2(100, 200)
velocity = Vector2(50, 0)
delta_time = 1 / 60

position += velocity * delta_time   # move right at 50 px/s
print(position)                     # Vector2(100.833, 200.000)

# Smoothly ease a value 20% of the way towards a target every call:
current = Vector2(0, 0)
target = Vector2(100, 100)
current = current.lerp(target, 0.2)  # Vector2(20.000, 20.000)

engine/utils/warnings.py

  • Responsibility: Defines one custom warning category, EngineWarning, used for catching misconfiguration at construction time - the moment you write BoxCollider2D(anchor="buttom"), not hours later while the game is running.
  • Depends on: nothing (subclasses the built-in UserWarning).
  • Used by: BoxCollider2D (bad anchor names), PlayerController (unknown movement_type).

Why a custom warning class, and why not just DebugManager?

There are two different kinds of "something's wrong" in this engine, and they're handled two different ways on purpose:

  1. A typo in your code (anchor="buttom" instead of "bottom") is a static mistake - it's wrong before the game even starts running, and the earlier you see it, the better. Python's built-in warnings module is built exactly for this: it prints a message with the exact file and line number where the mistake was made, the moment the offending line executes.
  2. Something noteworthy happens while the game is running (a coin was collected, a trigger callback crashed) is a runtime event - it belongs in DebugManager's log, which can also be shown in the on-screen overlay for a player or tester who isn't watching a console.

EngineWarning exists purely so that if you ever want to filter or silence engine warnings specifically (warnings.filterwarnings("ignore", category=EngineWarning)), you can, without also silencing unrelated warnings from other libraries.

What's inside

class EngineWarning(UserWarning):
    """Raised (via warnings.warn) for engine-level misconfiguration."""

That's the entire file - one class, no methods of its own (it inherits everything from UserWarning).

Example

import warnings
from engine.components.box_collider2d import BoxCollider2D

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    collider = BoxCollider2D(anchor="buttom", size=(32, 32))  # typo!

print(caught[0].category)   # <class 'engine.utils.warnings.EngineWarning'>
print(collider.anchor)      # "topleft"  (safe fallback, game keeps running)

Part 2 - The component system itself

engine/components/component.py

  • Responsibility: The base class every single behaviour in the engine inherits from. Defines the lifecycle (start()/update()) and the update_order mechanism that keeps that lifecycle predictable.
  • Depends on: nothing.
  • Used by: literally every other file under engine/components/, plus Transform and every gameplay script.

Why this file matters more than its size suggests

This is the smallest file in the engine (49 lines) but the most foundational one - every other component inherits from it, so a change here ripples everywhere. Two things are worth understanding well:

The start() / update() split. start() runs exactly once, the moment a component's GameObject enters a running scene. Use it to look up sibling components (other components on the same GameObject) - by the time start() runs, every component the object will ever have at construction time has already been attached, so get_component(...) lookups are safe. update(delta_time) runs every single frame after that. A common bug in component-based engines is doing sibling look-ups inside __init__ instead - at that point the other components might not have been added yet, so start() exists specifically to avoid that trap.

update_order. Every component has this class attribute, default 0. Before running update() on a GameObject's components each frame, GameObject sorts them by this number (ascending - lower numbers run first). This is what guarantees, for example, that Rigidbody2D always finishes moving something before Camera reads its position to decide where to point the camera, no matter what order you called add_component() in. See docs/ARCHITECTURE.md for the full table of which built-in component uses which value.

What's inside

class Component:
    update_order = 0

    def __init__(self): ...
    def start(self): ...
    def update(self, delta_time): ...
Member What it does
update_order (class attribute, default 0) Controls scheduling - see above. Override it on a subclass (class MyThing(Component): update_order = -50) or on a single instance (my_component.update_order = 50) to change when it runs relative to everything else.
game_object Set automatically by GameObject.add_component() - a back-reference to the object this component is attached to. None until then.
enabled True by default. GameObject.update() skips update() entirely for any component with enabled = False - this is the on/off switch for a component without removing it.
start() Called once. Safe to call more than once - a private _has_started flag makes every call after the first a no-op, so you never need to guard against double-initialization yourself. Override this, not __init__, to look up sibling components.
update(delta_time) Called once per frame, only while both the component (enabled) and its GameObject (active) are alive. Does nothing by default - override it in a subclass. delta_time is the number of seconds since the last frame (see Engine.run() in Part 11), so multiplying anything "per second" by it gives you "this frame's worth".

Example - the minimum viable custom component

from engine.components.component import Component

class SpinForever(Component):
    """Spins this object's Transform at a constant rate. A tiny, complete
    example of the pattern every component in this engine follows."""

    def __init__(self, degrees_per_second=90):
        super().__init__()          # always call this first
        self.degrees_per_second = degrees_per_second

    def update(self, delta_time):
        self.game_object.transform.rotation += self.degrees_per_second * delta_time

# usage:
enemy.add_component(SpinForever(degrees_per_second=180))

engine/components/transform.py

  • Responsibility: Holds position, rotation, and scale - the one piece of state that's true of every object in the game, regardless of what else it does.
  • Depends on: Component (it is one), Vector2 (for position and scale).
  • Used by: GameObject (creates one automatically), and read/written by nearly every other component (Rigidbody2D, BoxCollider2D, SpriteRenderer, Camera, PlayerController, Scene.render).

Why every GameObject gets one automatically

Requirement: "every object must have position, rotation, and scale." Rather than making Transform optional and having every other component null-check for it, GameObject.__init__ creates one unconditionally (see Part 3) - so any component can safely write self.game_object.transform.position.x without ever checking whether a transform exists first.

What's inside

class Transform(Component):
    def __init__(self, x=0.0, y=0.0, rotation=0.0, scale_x=1.0, scale_y=1.0): ...
Member Type What it does
position Vector2 World-space location, in pixels.
rotation float Degrees, 0 = unrotated. Nothing in the base engine reads this yet - SpriteRenderer always draws axis-aligned - but it's tracked so rotation is a well-defined property of every object rather than something each new feature has to invent its own convention for. See "Where this is heading" in docs/ARCHITECTURE.md for how you'd wire up rotated rendering.
scale Vector2 (1, 1) = original size. Also not yet consumed by rendering, for the same reason.
translate(dx, dy) method Shorthand for position.x += dx; position.y += dy.

You never call GameObject.add_component(Transform(...)) yourself - see Part 3 for exactly how/when it's created.

Example

# Somewhere inside a component's update():
transform = self.game_object.transform
transform.position.x += 5             # move right 5px
transform.rotation += 90              # spin 90 degrees (tracked, not yet drawn)
transform.scale.x = 2.0               # tracked, not yet drawn - see docs/ARCHITECTURE.md

# GameObject.x / .y still work too - they're aliases for the same data:
assert self.game_object.x == transform.position.x

Part 3 - GameObject

engine/game_object.py

  • Responsibility: A named container for components, plus the glue that runs their start()/update() in the right order. This class intentionally has almost no behaviour of its own.
  • Depends on: Transform (creates one for every instance).
  • Used by: everything - every scene-building function creates GameObjects, every component receives one via self.game_object.

The mental model

Think of GameObject as a filing folder with a label (name) and a stack of index cards (components) inside it. The folder itself can't do anything - it just makes sure every card gets looked at, in the right order, every frame. All the actual behaviour is written on the cards (the components).

What's inside

class GameObject:
    def __init__(self, x=0.0, y=0.0, name="GameObject"): ...
Member Signature What it does
transform Transform Created automatically in __init__ - always valid, never None.
name str Just a label. Scene.find_game_object(name) searches by this.
active bool, default True If False, this object is skipped entirely: update() returns immediately, and Scene.get_components() (which almost everything else queries through) won't return any of its components either - so it stops rendering, colliding, and updating all at once. This is how CoinPickup "removes" a collected coin without actually deleting it.
scene Scene | None Set by Scene.add_game_object(). Lets a component reach the wider world, e.g. Rigidbody2D uses self.game_object.scene.get_components(BoxCollider2D) to find things it might collide with.
add_component(component) (Component) -> Component Attaches a component: sets component.game_object = self, appends it to the internal list, and - if this GameObject is already running (_started) - calls the new component's start() immediately, so components can be added dynamically after the game has begun and still initialize correctly. Returns the same component you passed in, so you can capture a reference in one line (see the example below).
get_component(cls) (type) -> Component | None The first attached component that is an instance of cls, or None. This is how components look up their siblings, e.g. self.game_object.get_component(Rigidbody2D).
get_components(cls) (type) -> list[Component] Every attached component that's an instance of cls - plural, for the (currently rare) case of more than one of the same type on one object.
remove_component(component) (Component) -> None Detaches it.
start() () -> None Called once (guarded by _started, same one-shot pattern as Component.start()) - runs start() on every attached component, in update_order. Normally you don't call this yourself; Scene.add_game_object() calls it for you the moment the object joins a scene.
update(delta_time) (float) -> None If active, runs update(delta_time) on every enabled attached component, in update_order. This is the method Scene.update() calls on every object each frame.
x, y (properties) float Thin aliases for transform.position.x / .y. Reading go.x reads go.transform.position.x; writing go.x = 5 writes go.transform.position.x = 5. Kept purely so older/simpler code that expects flat x/y attributes keeps working - transform is the real, canonical state.

A private implementation detail worth knowing: _ordered_components() caches the update_order-sorted list and only re-sorts when a component is actually added or removed (_order_dirty flag). Sorting a handful of components every single frame for every object would be wasteful; this way it only happens when the component list actually changes.

Example

from engine.game_object import GameObject
from engine.components.sprite_renderer import SpriteRenderer
from engine.components.rigidbody2d import Rigidbody2D

enemy = GameObject(x=300, y=100, name="Slime")

# add_component returns what you pass in, so you can grab a reference
# and configure it in the same line:
rb = enemy.add_component(Rigidbody2D(gravity=900, use_gravity=True))
enemy.add_component(SpriteRenderer(sprite=slime_image))

rb.velocity.x = -50   # start it moving left immediately

print(enemy.get_component(Rigidbody2D) is rb)   # True
enemy.active = False                            # instantly "removes" it from
                                                  # update/render/collision
                                                  # without deleting it

Part 4 - Rendering

engine/components/sprite_renderer.py

  • Responsibility: Holds the image to draw for this GameObject, and the data Scene.render() needs to decide when to draw it relative to everything else.
  • Depends on: Component.
  • Used by: Scene.render() (reads sprite, z_index, offset_y, enabled), Animator (writes sprite every time the frame advances), BoxCollider2D (reads sprite.get_size() once, if you don't pass an explicit collider size).

What it does not do

SpriteRenderer doesn't draw anything itself - it has no update() override at all. It's purely a data holder that Scene.render() (Part 9) reads from every frame. Keeping "what to draw" (this file) separate from "the logic that walks every object and actually draws it" (Scene) means you never need one SpriteRenderer instance to know about any other - Scene is the only thing that needs the full picture.

What's inside

class SpriteRenderer(Component):
    def __init__(self, sprite=None, z_index=0, offset_y=0): ...
Member What it does
sprite A pygame.Surface (or None to render nothing). Scene.render() skips any renderer whose sprite is falsy.
z_index Layer order - lower draws first (further back). Objects are grouped by this before anything else.
offset_y Only affects sort order, not the actual draw position - see below.
set_sprite(sprite) Just self.sprite = sprite. Exists mainly for readability at call sites (renderer.set_sprite(new_image) reads more clearly than a bare attribute assignment in some contexts) and as a natural override point if a subclass ever wants to react to the sprite changing.

Why offset_y exists and what it's for: within the same z_index, Scene.render() sorts objects by transform.position.y + offset_y and draws them in that order, so an object lower on screen draws in front of one higher up - a cheap, common trick for faking depth in top-down or platformer art (a character standing "in front of" a tree because their feet are lower on screen). offset_y lets you nudge where on the sprite counts as "the feet" for this comparison, without moving the sprite itself - useful when a sprite's canvas has empty space above the actual character (common with tall idle/attack animation frames).

Example

from engine.components.sprite_renderer import SpriteRenderer

renderer = enemy.add_component(SpriteRenderer(sprite=slime_image, z_index=1))

# swap the image later (e.g. a hit-flash effect):
renderer.set_sprite(slime_hit_image)

# hide something without removing it or its collider:
renderer.enabled = False

Part 5 - Physics and collision

These two files work as a pair: BoxCollider2D is the shape (where an object is, for collision purposes), and Rigidbody2D is the behaviour (how an object moves, and what it does when its shape overlaps another one). Read BoxCollider2D first - Rigidbody2D calls straight into it.

engine/components/box_collider2d.py

  • Responsibility: An axis-aligned rectangle (a pygame.Rect) attached to a GameObject's transform, usable either as a solid obstacle or as a trigger (overlap-only, no physical blocking).
  • Depends on: Component, SpriteRenderer (to auto-measure a size if you don't give one explicitly), EngineWarning (for anchor validation).
  • Used by: Rigidbody2D (the main consumer - collides against every other collider in the scene), Scene (_process_triggers, which calls check_trigger_events), DebugManager (draws every collider's outline when the F2 overlay is on), gameplay scripts like CoinPickup (checks is_trigger, subscribes to on_trigger_enter).

The one thing to understand before anything else: sizing

You can pass an explicit size - BoxCollider2D(size=(32, 48)) - or leave it out and let the collider measure itself from whatever sprite is on this object's SpriteRenderer, once, in start(). After that initial measurement, the size is locked (_locked_size) and will not change again automatically, even if the sprite later changes (e.g. an Animator swapping to a differently-sized frame). This is deliberate, not an oversight - see docs/CHANGELOG.md for the full story of why a collider that kept re-measuring itself every frame caused visible jitter. If you genuinely want the hitbox to change size later (a crouch, for instance), call set_size(w, h) explicitly.

The other thing to understand: anchors

anchor is one of pygame's 9 named rectangle anchor points - "topleft", "midtop", "topright", "midleft", "center", "midright", "bottomleft", "midbottom", "bottomright". It controls which point of the rectangle sits exactly at the GameObject's transform position (+ offset_x/offset_y). An unrecognized string (a typo) raises an EngineWarning and safely falls back to "topleft" rather than silently doing the wrong thing forever. Important caveat: SpriteRenderer always draws from the transform position as a top-left corner, with no concept of anchors at all - so a collider anchored at anything other than "topleft" will not visually line up with its own sprite unless you account for the offset yourself. Both objects in the bundled demo scene use "topleft" for exactly this reason.

What's inside

class BoxCollider2D(Component):
    update_order = -90

    def __init__(self, size=None, offset_x=0, offset_y=0,
                 anchor="topleft", is_trigger=False): ...
Member Signature What it does
rect pygame.Rect The current hitbox in world space. Pixel-quantized - pygame rounds every coordinate to the nearest whole number, which matters for the "exact snapping" methods below.
is_trigger bool False (default) = solid, physically blocks Rigidbody2D movement. True = overlap-only; nothing physically stops moving through it, but on_trigger_* callbacks fire.
on_trigger_enter / on_trigger_stay / on_trigger_exit list[callable] Lists of callback(trigger, other) functions. Append to subscribe - collider.on_trigger_enter.append(my_function). More than one listener is fine; nothing gets overwritten.
start() () -> None Looks up this object's SpriteRenderer, measures the initial size if none was given explicitly, and builds the first rect.
set_size(width, height) (int, int) -> None Explicitly (re)locks the collider to a new size, and immediately rebuilds rect. Use this for a deliberate, intentional resize (crouching, power-ups) - it's the only supported way to change a collider's size after construction.
update(delta_time) (float) -> None Rebuilds rect from the current transform position (_update_rect). delta_time is accepted for interface consistency with other components but unused - a collider's shape doesn't depend on how much time has passed.
snap_left_to / snap_right_to / snap_top_to / snap_bottom_to(world_coord) (float) -> None Moves the owning Transform (not just the rect) so this collider's named edge sits exactly at world_coord, computed with exact (unrounded) float arithmetic. Rigidbody2D calls these to resolve collisions - see _anchor_to_topleft_offset() below for how they stay correct for every anchor, and docs/CHANGELOG.md for exactly why "exact" matters here.
check_trigger_events(other_collider) (BoxCollider2D) -> None Compares this frame's overlap state against last frame's (_overlapping_colliders) and fires the matching callback list: on_trigger_enter the first frame two colliders touch, on_trigger_stay every frame after that while they're still touching, on_trigger_exit the frame they stop. Called by Scene._process_triggers() - you don't normally call this yourself.

Private helpers, in case you're extending the collision system: _measure_from_sprite() (reads the current sprite's size, used only during the one-time start() measurement), _update_rect() (rebuilds rect from the transform + offset + anchor), _anchor_to_topleft_offset() (computes, for the current anchor and locked size, how far the anchor point is from the rectangle's top-left corner - this is what makes the snap_*_to methods work correctly for any anchor, not just "topleft"), and _dispatch(callbacks, other_collider) (calls every subscribed callback in a copy of the list - so a callback that unsubscribes itself mid-call doesn't crash the iteration - wrapped in a try/except that logs via DebugManager instead of ever letting a broken gameplay script take down the whole game).

Example

from engine.components.box_collider2d import BoxCollider2D

# Solid, explicit size (recommended for anything with an Animator):
player.add_component(BoxCollider2D(size=(32, 48), anchor="topleft"))

# Trigger, size auto-measured from whatever sprite is on this object:
coin_collider = coin.add_component(BoxCollider2D(is_trigger=True))

def on_touch(trigger, other):
    print(f"{other.game_object.name} touched the coin!")

coin_collider.on_trigger_enter.append(on_touch)

engine/components/rigidbody2d.py

  • Responsibility: Applies gravity and drag to a velocity, moves the GameObject's transform by that velocity every frame, and resolves collisions against every other solid BoxCollider2D in the scene.
  • Depends on: Component, BoxCollider2D (both to look up its own collider and to query the scene for others), DebugManager (a one-time warning if there's no collider to work with), Vector2 (velocity).
  • Used by: PlayerController (reads/writes velocity and is_grounded), scenes/game_scene.py (attaches one to the player).

The physics model, in plain terms

Every frame, in this order:

  1. If use_gravity, add gravity * gravity_scale * delta_time to downward velocity (this is what makes falling accelerate over time, rather than falling at a constant speed).
  2. Clamp downward velocity to terminal_velocity (a falling body can't speed up forever).
  3. Apply drag to horizontal velocity (a damping factor - higher drag means horizontal movement slows down faster once you stop pressing a direction).
  4. Move on the X axis, then immediately check + resolve X collisions.
  5. Move on the Y axis, then immediately check + resolve Y collisions.

Resolving X completely before starting Y (rather than moving diagonally and resolving both at once) is a standard simplification for axis-aligned physics - it avoids ambiguous "which axis do I push you back along" cases when a corner is involved, at the cost of not perfectly handling extremely thin or fast-moving obstacles (irrelevant at the scale this engine operates at).

What's inside

class Rigidbody2D(Component):
    update_order = -100
    GROUND_PROBE_DISTANCE = 4

    def __init__(self, gravity=500, gravity_scale=1.0, drag=0.0, mass=1.0,
                 use_gravity=True, is_kinematic=False, terminal_velocity=1000): ...
Member Signature What it does
velocity Vector2 Pixels/second, in both axes. The canonical way to read or set speed.
velocity_x, velocity_y float (properties) Back-compat aliases for velocity.x / .y, for code written against the original flat-float API.
is_grounded bool True while resting on something solid below. Stable/non-flickering by design - see GROUND_PROBE_DISTANCE below.
is_kinematic bool True = moves under its own velocity every frame, but ignores gravity/forces entirely and is never pushed around by collisions with other bodies - the standard setup for a moving platform.
mass float Used by add_impulse/add_force below. Silently forced to 1.0 if you pass 0 or a negative number, to avoid a divide-by-zero.
start() () -> None Looks up this object's BoxCollider2D. If there isn't one, logs a one-time warning via DebugManager (gravity/velocity will still apply to the transform, but nothing will physically collide).
add_impulse(impulse_x, impulse_y) (float, float) -> None An instant velocity change: Δv = impulse / mass. Divides by mass so a heavier body needs a bigger impulse for the same speed change - use this for one-off events like an explosion knockback.
add_force(force_x, force_y, delta_time) (float, float, float) -> None A continuous force, integrated over one frame: Δv = (force / mass) * delta_time. Requires delta_time explicitly because a force only means something over a span of time - call this every frame (typically from inside another component's own update(delta_time)) for a sustained effect like wind or a rocket thruster.
stop() () -> None Zeroes velocity on both axes.
update(delta_time) (float) -> None Runs the full physics step described above.

Private helpers (the actual collision-resolution machinery): _sync_collider() (tells this object's BoxCollider2D to rebuild its rect right now, so collision checks always see the up-to-date position - called after every axis move), _solid_colliders() (every non-trigger collider on an active object elsewhere in the scene - i.e. everything this body could possibly hit), _resolve_collisions_x() / _resolve_collisions_y() (the actual per-axis collision response: detect overlap, call the matching snap_*_to on this object's collider, zero the velocity on that axis), and _probe_for_ground() (checks a few pixels below the collider for solid ground whenever there's no direct overlap this frame - this is GROUND_PROBE_DISTANCE in action, and it's specifically what stops is_grounded from flickering True/False every other frame while standing still; see docs/CHANGELOG.md for the full derivation of why that flicker happens without it).

Example

from engine.components.rigidbody2d import Rigidbody2D

rb = player.add_component(Rigidbody2D(gravity=900, use_gravity=True, drag=3.0))

# a one-off jump:
if rb.is_grounded:
    rb.add_impulse(0, -500)

# a sustained force, applied every frame while a "wind zone" is active:
def apply_wind(delta_time):
    rb.add_force(200, 0, delta_time)   # a steady push to the right

Part 6 - Animation

engine/components/animator.py

  • Responsibility: Cycles a SpriteRenderer's sprite through a list of frames over time, based on a dictionary of named animations.
  • Depends on: Component, SpriteRenderer (writes .sprite directly every time the frame advances).
  • Used by: PlayerController (calls play(...) every frame with whichever animation matches the current movement state).

The one rule that makes this component safe to use the way PlayerController uses it

PlayerController doesn't call play("walk_right") once when the player starts moving right - it calls it on every single frame the right key is held down, every frame, unconditionally. play() is specifically designed to make that safe: calling it repeatedly with the same name that's already playing does not restart the animation - it's a no-op past updating a couple of flags. It only resets playback position (back to frame 0, timer back to 0) when the animation name is actually different from what's currently playing, or the previous playback had already finished. See docs/CHANGELOG.md for what goes wrong without this rule (the short version: the animation gets permanently stuck on its first frame).

What's inside

class Animator(Component):
    def __init__(self, animations=None, default_animation=None, frame_duration=0.1): ...

animations is a plain dict: {"idle": [surf1, surf2], "jump": [surf3]}

  • each value is a list of pygame.Surface frames.
Member Signature What it does
current_animation str | None The name currently playing.
frame_index int Which frame of current_animation is currently showing.
is_playing bool False once a non-looping animation reaches its last frame.
loop bool Whether the current animation restarts from the beginning after its last frame (True) or stops there (False).
start() () -> None Looks up this object's SpriteRenderer and applies whatever frame default_animation/frame_index currently point to.
play(anim_name, loop=True, reverse=False) (str, bool, bool) -> None See "the one rule" above. Does nothing if anim_name isn't a key in animations.
pause() () -> None Stops advancing, but leaves frame_index exactly where it is - resuming later (via play() with the same name) continues rather than restarting.
stop() () -> None Stops and resets to frame 0.
update(delta_time) (float) -> None Accumulates delta_time into an internal timer; every time it crosses frame_duration, advances one frame (carrying over any leftover time rather than discarding it, so long-run animation timing doesn't slowly drift from real time) and pushes the new frame onto the SpriteRenderer. Uses a while loop rather than if, so if an unusually large delta_time arrives (e.g. right after a brief freeze), the animation catches up by however many frames actually elapsed instead of only ever advancing one frame per update() call no matter how much time passed.

Private helpers: _advance_frame(frames) (the actual "move frame_index forward or backward by one, handling looping/stopping at the ends" logic - separated out so update()'s while-loop body stays readable), _apply_current_frame() (pushes animations[current_animation] [frame_index] onto the SpriteRenderer, with bounds-checking so an out-of-range frame_index can never crash it).

Example

from engine.components.animator import Animator

anim = player.add_component(Animator(
    animations={
        "idle": [idle_frame],
        "walk_right": [walk_1, walk_2, walk_3, walk_4],
        "jump": [jump_frame],
    },
    default_animation="idle",
    frame_duration=0.12,   # seconds per frame
))

# Safe to call every frame - this is exactly what PlayerController does:
def update(self, delta_time):
    if moving_right:
        anim.play("walk_right")          # keeps advancing normally
    else:
        anim.play("idle")

# A one-shot animation that shouldn't loop:
anim.play("jump", loop=False)

Part 7 - Input and movement

engine/components/player_controller.py

  • Responsibility: Turns keyboard input into movement - either 4/8-directional top-down movement, or run-and-jump platformer movement driven through a Rigidbody2D. Also picks which animation should be playing based on the current movement state, if an Animator is present.
  • Depends on: Component, Animator and Rigidbody2D (both looked up, optionally - this component works even without either, see below), DebugManager (one-time setup warning), EngineWarning (bad movement_type), and pygame directly (reads pygame.key.get_pressed() and uses pygame.K_* constants for default keybinds).
  • Used by: scenes/game_scene.py (attaches one to the player).

The two modes

  • "top_down" (the default): reads all four directions, normalizes diagonal movement so it's not faster than moving straight (_DIAGONAL_FACTOR = 1/√2), and either sets Rigidbody2D.velocity directly (if one is attached) or moves the transform directly (if not - see "Working without a Rigidbody2D" below). No gravity involved.
  • "platformer": reads left/right only, requires a Rigidbody2D to do anything useful (jumping sets rigidbody.velocity.y directly), and adds two well-known "game feel" techniques on top of the bare minimum - see below.

Coyote time and jump buffering

These are the two __init__ parameters coyote_time and jump_buffer_time (both default 0.1 seconds, both disabled by setting to 0):

  • Coyote time: for a short window after walking off a ledge, a jump still registers, as if the player were still grounded. Named after the classic cartoon physics of a character not falling until they look down - without this, platformers feel unfairly strict, because in practice players often press jump a frame or two after visually leaving a platform.
  • Jump buffering: if you press jump slightly before landing, the jump still fires the instant you touch down, instead of being silently dropped because is_grounded wasn't True yet at the exact frame you pressed the key.

Both are implemented as simple countdown timers (_time_since_grounded, _time_since_jump_pressed), checked by _can_jump() / _wants_to_jump().

Working without a Rigidbody2D or Animator

Both are optional. If self.rigidbody is None (checked via a plain if self.rigidbody:), top_down mode falls back to moving the transform directly instead of setting velocity, and platformer mode's horizontal movement does the same (though platformer mode's jumping does nothing without a rigidbody, since gravity/jump velocity has nowhere to live - a warning is logged once in start() if you combine movement_type= "platformer" with no Rigidbody2D). If self.animator is None, the _play_*_animation methods just return immediately - no crash, no warning, animation is simply optional.

What's inside

class PlayerController(Component):
    def __init__(self, speed=200, jump_force=350, movement_type="top_down",
                 keybinds=None, anim_map=None,
                 coyote_time=0.1, jump_buffer_time=0.1): ...
Member What it does
speed Pixels/second for horizontal (and, in top-down mode, vertical) movement.
jump_force The speed (not force, despite the name kept for API continuity) imparted upward the instant a jump fires - rigidbody.velocity.y is set to exactly -jump_force.
movement_type "top_down" or "platformer". Anything else logs an EngineWarning immediately in __init__ and results in update() doing nothing every frame (rather than crashing).
keybinds dict[str, list[int]], defaults to WASD + arrow keys + Space (DEFAULT_KEYBINDS). Keys are the action names "left"/"right"/"up"/"down"/"jump"; values are lists of pygame.K_* constants, any of which count as "pressed" for that action.
anim_map dict[str, str], defaults to DEFAULT_ANIM_MAP - maps controller states ("idle", "walk_right", "jump", ...) to the animation names you actually used when building your Animator. Override any subset if your animation names differ.
start() Looks up Animator and Rigidbody2D on the same object (both optional - see above).
update(delta_time) Reads pygame.key.get_pressed() once, then dispatches to _update_top_down or _update_platformer based on movement_type.

The extension hooks (override these in a subclass rather than rewriting update() from scratch):

Hook Called Typical use
_on_jump() The instant a jump fires (inside _perform_jump) Play a jump sound, spawn a dust particle, decrement a "jumps remaining" counter for a double-jump ability.
_read_move_axis(keys) Every frame, to turn the keyboard into a (move_x, move_y) pair Support a gamepad or a different input scheme.
_play_platformer_animation(move_x) / _play_top_down_animation(move_x, move_y) Every frame, after movement is applied Add extra animation states (e.g. a "skid" animation when reversing direction quickly).
_can_jump() / _wants_to_jump() Every frame in platformer mode, to decide whether to actually call _perform_jump() Add extra jump conditions - see the double-jump example below.

Other private methods, for completeness: _is_pressed(keys, action) (checks whether any key bound to action is currently held), _update_top_down / _update_platformer (the two mode-specific update bodies), _update_jump_timers (advances the coyote-time/jump-buffer countdowns each frame), _perform_jump (sets the jump velocity, clears is_grounded, and pushes both grace-window timers past their thresholds so a single press can't double-trigger a second jump the very next frame).

Example - basic setup

from engine.components.player_controller import PlayerController

player.add_component(PlayerController(
    speed=200,
    jump_force=500,
    movement_type="platformer",
))

Example - extending it with a double jump

class DoubleJumpController(PlayerController):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.jumps_left = 2

    def _can_jump(self):
        return super()._can_jump() or self.jumps_left > 0

    def _on_jump(self):
        self.jumps_left -= 1

    def _update_jump_timers(self, keys, delta_time):
        super()._update_jump_timers(keys, delta_time)
        if self.is_grounded:
            self.jumps_left = 2   # refill the moment we touch ground again

Part 8 - The camera

engine/components/camera.py

  • Responsibility: Tracks a "virtual viewpoint" position and, on request, produces the offset Scene.render() needs to subtract from every sprite's world position so the world appears to scroll around whatever the camera is following.
  • Depends on: Component, Vector2, the standard library math (for the exponential smoothing formula).
  • Used by: Scene.render() (calls get_offset()), DebugManager.draw_colliders() (same, so collider outlines line up with the shifted world), main.py (creates and configures one).

The core idea, restated simply

A Camera never moves anything else. It just remembers one number pair (position - the world coordinate that should appear at the center of the screen) and updates that number every frame to track a target. When it's time to draw, Scene.render() asks the camera "what offset should I apply?" and shifts every single sprite (the target included) by that amount before drawing it. The player's actual position never changes because of the camera - only where on screen everything gets drawn does.

What's inside

class Camera(Component):
    update_order = 100

    def __init__(self, target=None, follow_speed=5.0,
                 smooth_follow=True, position=None): ...
Member Signature What it does
target GameObject | None What to follow. None means "no target" - see below.
follow_speed float How quickly the camera closes the gap to the target, when smooth_follow is on. Higher = snappier, lower = laggier/floatier.
smooth_follow bool True (default): eases towards the target every frame (see the math below). False: snaps exactly onto the target's position every single frame - "hard follow", zero lag.
position Vector2 The camera's current world-space center point. You can pass a starting value to __init__, but it only matters before start() runs, which immediately snaps to the target if one is set (so the camera never visibly "flies in" from an arbitrary starting point when the game begins).
start() () -> None Calls snap_to_target().
set_target(target, snap=True) (GameObject, bool) -> None Switches which object to follow. snap=True (default) re-centers instantly; snap=False eases into the new target smoothly from wherever the camera currently is.
snap_to_target() () -> None Instantly moves position onto the target's current position, no easing. Useful right after a teleport or respawn, where you don't want the camera visibly catching up.
update(delta_time) (float) -> None If there's no target, does nothing at all (world stays static - see below). Otherwise moves position towards the target, either instantly or via the smoothing formula below.
get_offset(screen_width, screen_height) (int, int) -> Vector2 The world coordinate that will land at the screen's top-left corner. Always exactly Vector2(0, 0) when target is None - not "whatever position happens to be," an explicit, unconditional zero, which is what guarantees a targetless camera renders the world completely unshifted.
world_to_screen(world_pos, screen_width, screen_height) (Vector2, int, int) -> Vector2 Convenience wrapper: world_pos - get_offset(...).

The smoothing formula, and why it's not a simple lerp

t = 1.0 - math.exp(-self.follow_speed * delta_time)
self.position = self.position.lerp(target_pos, t)

A more obvious-looking approach would be position.lerp(target, follow_speed * delta_time) directly. The problem: that approach's effective smoothing strength depends on the frame rate. At a low enough frame rate, follow_speed * delta_time can exceed 1.0, which makes the camera overshoot the target and then correct back the other way next frame - visible shake, caused entirely by the camera math itself, with nothing to do with how the player is moving. The exponential form above converges towards the target at the same rate regardless of frame rate, and mathematically cannot produce a t outside [0, 1] (lerp also clamps t itself as a second layer of protection), so this particular flavor of camera shake simply cannot occur, at any frame rate.

No target = no camera, by design

update() returns immediately if target is None, so position simply never changes. get_offset() returns a hard-coded Vector2(0, 0) in the same case. Combined, this means a Camera with no target has zero effect on rendering - the world draws at raw, unshifted coordinates, identical to there being no camera component at all. This is what satisfies "if the camera has no target, the game should behave normally."

Example

from engine.game_object import GameObject
from engine.components.camera import Camera

camera_object = GameObject(name="Main Camera")
camera = camera_object.add_component(
    Camera(target=player, follow_speed=6.0, smooth_follow=True)
)
scene.add_game_object(camera_object)
scene.set_active_camera(camera)

# later, e.g. moving to a boss-fight arena:
camera.set_target(boss_arena_focus_point, snap=True)

# a hard-follow (no smoothing) camera, e.g. for a puzzle game with no
# platforming feel to protect:
Camera(target=player, smooth_follow=False)

Part 9 - Managing the world

engine/scene.py

  • Responsibility: Owns a flat list of GameObjects and drives them: updating, trigger-checking, and rendering, once per frame.
  • Depends on: BoxCollider2D (trigger processing), SpriteRenderer (rendering), Vector2 (the zero-offset default when there's no camera).
  • Used by: SceneManager (holds the active one), Engine.run() (calls update()/render() every frame via SceneManager), scenes/game_scene.py (creates and populates one).

What "engine machinery, not game content" means here

This file knows how to update/collide/render any collection of GameObjects - it has no idea what a player or a coin is, and it never will. What actually goes into a scene (which objects, with which components) is entirely the job of files like scenes/game_scene.py (Part 12). This is the engine/content split described in the project map at the top of this document, applied concretely.

What's inside

class Scene:
    def __init__(self, name="Scene"): ...
Member Signature What it does
game_objects list[GameObject] Every object in this scene, in the order they were added.
active_camera Camera | None Whichever Camera component's offset should be applied during rendering. None = render at raw world coordinates.
add_game_object(go) (GameObject) -> GameObject Sets go.scene = self, appends it, and immediately calls go.start() - so every component on it must already be attached before you call this (that's why every example in this guide calls add_component(...) several times then scene.add_game_object(go) last). Returns the same object you passed in.
remove_game_object(go) (GameObject) -> None Removes it from the list entirely (contrast with go.active = False, which keeps it in the list but skips it everywhere - usually what you want for "temporarily gone", while this is for "permanently gone").
find_game_object(name) (str) -> GameObject | None First object whose .name matches, or None. This is how main.py gets a reference to the player after build_game_scene() returns, without game_scene.py needing to hand back individual object references.
set_active_camera(camera) (Camera) -> None Just self.active_camera = camera.
get_components(component_type) (type) -> list[Component] Every component of that type, across every active GameObject in the scene. This is the backbone almost everything else queries through: Rigidbody2D uses it to find things to collide with, Scene.render() uses it to find every SpriteRenderer, DebugManager uses it to find every collider to outline.
start() () -> None Calls start() on every object currently in the scene. (In practice this rarely needs calling directly, since add_game_object already starts each object as it's added - but it's there for the case of building every object first and starting the whole batch at once.)
update(delta_time) (float) -> None Calls update(delta_time) on every active object, then processes trigger overlaps (_process_triggers). Iterates over list(self.game_objects) (a snapshot copy) specifically so that a component which adds or removes a GameObject mid-update doesn't corrupt the loop.
render(screen) (pygame.Surface) -> None Computes the camera offset (or zero if there's no active camera), collects every enabled SpriteRenderer with a non-empty sprite, sorts them by (z_index, y-position), and blits each one at its world position minus the offset.

Private helper: _process_triggers() - finds every trigger collider in the scene, and for each one, checks it against every other collider (trigger or solid) via check_trigger_events. This is what actually fires on_trigger_enter/_stay/_exit - see Part 5's BoxCollider2D section for what happens on the receiving end.

Example

from engine.scene import Scene
from engine.game_object import GameObject
from engine.components.sprite_renderer import SpriteRenderer

scene = Scene(name="level_1")

tree = GameObject(x=400, y=200, name="Tree")
tree.add_component(SpriteRenderer(sprite=tree_image))
scene.add_game_object(tree)   # starts it immediately

found = scene.find_game_object("Tree")
assert found is tree

scene.update(delta_time=1/60)
scene.render(screen)

engine/scene_manager.py

  • Responsibility: Owns every Scene you've registered, by name, and tracks which one is currently active.
  • Depends on: DebugManager (logs an error if you ask to activate a scene name that was never registered).
  • Used by: Engine (owns one; Engine.load_scene() is a thin convenience wrapper over this class's two main methods).

Why this file exists separately from Scene

Scene (above) is "one level's worth of objects." SceneManager is "the switchboard that knows about every level and which one is live right now" - the thing you'd use to implement a level-select screen or a transition from a menu scene to a gameplay scene. In the original project this class existed as an empty, disconnected stub that nothing ever called; it's now the real, working thing, and Engine owns one.

What's inside

class SceneManager:
    def __init__(self): ...
Member Signature What it does
scenes dict[str, Scene] Every registered scene, by name.
active_scene Scene | None Whichever one is currently live.
add_scene(name, scene) (str, Scene) -> Scene Registers scene under name (also sets scene.name = name, so the two always agree). Returns the scene you passed in.
set_active(name) (str) -> Scene | None Makes the named scene active and calls its start(). If name was never registered, logs an error via DebugManager and returns None instead of raising an exception - a typo'd scene name degrades gracefully (nothing renders/updates) rather than crashing the whole game.
get_scene(name) (str) -> Scene | None Looks a scene up by name without activating it - useful for e.g. pre-loading a scene in the background before switching to it.
update(delta_time) (float) -> None Forwards to active_scene.update(delta_time), if there is one.
render(screen) (pygame.Surface) -> None Forwards to active_scene.render(screen), if there is one.

Example

from engine.scene_manager import SceneManager

manager = SceneManager()
manager.add_scene("menu", build_menu_scene())
manager.add_scene("level_1", build_game_scene())

manager.set_active("menu")
# ... later, when the player clicks "Start" ...
manager.set_active("level_1")

Part 10 - Diagnostics

engine/debug_manager.py

  • Responsibility: Two things bundled together because they're both "developer-facing runtime diagnostics": a leveled log (info/warning/error), and an on-screen overlay that shows the log plus FPS and object counts, plus a separate toggle to outline every collider in the scene.
  • Depends on: BoxCollider2D (for the collider-outline feature), pygame (fonts and drawing), the standard library time and collections.deque.
  • Used by: Engine (creates one, calls update()/draw_overlay()/ draw_colliders() every frame, wires F1/F2 to toggle them), and directly by nearly every other engine file that needs to report a problem - Rigidbody2D, PlayerController, BoxCollider2D, SceneManager, CoinPickup all call DebugManager.log_warning(...) or similar without needing a reference passed in (see "two ways to log" below).

Two ways to log, landing on the same place

DebugManager.log_warning("...")     # from anywhere - no reference needed
engine.debug.log_warning("...")     # via the specific instance Engine created

Both work identically. DebugManager keeps a class-level _instance pointer, set by whichever instance was created most recently; the classmethods (log_info/log_warning/log_error) are just convenience wrappers that call through to that instance. This mirrors Unity's static Debug.Log(...) - in a component-based engine, threading an explicit logger reference through every single class that might want to report something would be a lot of ceremony for very little benefit. If you never construct a DebugManager yourself and just start calling DebugManager.log_warning(...), instance() creates a default one for you automatically the first time it's needed.

LogEntry - the small helper class at the top of the file

class LogEntry:
    def __init__(self, level, message, source, timestamp): ...
    def format(self): ...

Just a plain data holder for one log line (level, message, optional source tag, timestamp) plus a format() method that renders it as "[LEVEL][source] message" (or "[LEVEL] message" if there's no source) - used both for console printing and for the on-screen overlay.

What's inside DebugManager

class DebugManager:
    def __init__(self, history_size=200, overlay_lines=8, print_to_console=True): ...
Member Signature What it does
logs deque[LogEntry] The last history_size log entries (older ones fall off automatically - it's a bounded ring buffer, so a long play session can't leak memory).
show_overlay / show_colliders bool The two independent on/off switches, toggled by F1/F2 respectively.
fps float Recomputed twice a second (see update() below), not every single frame - a per-frame FPS number is too noisy to read.
log_info(message, source=None) / log_warning(...) / log_error(...) classmethod (str, str) -> None Records a timestamped entry and (unless print_to_console=False) prints it immediately. source is an optional tag - e.g. DebugManager.log_warning("...", source="Rigidbody2D") - shown alongside the message.
toggle_overlay() / toggle_colliders() () -> None Flips the matching bool. What F1/F2 actually call.
update(delta_time) (float) -> None Accumulates frame count and time; every half-second, recomputes fps from however many frames happened in that window.
draw_overlay(screen, scene=None) (pygame.Surface, Scene) -> None Does nothing if show_overlay is False. Otherwise draws a semi-transparent panel in the top-left with the current FPS, the active scene's name and object count, the F1/F2 hint, and the most recent log lines - color-coded by level (info = blue, warning = yellow, error = red).
draw_colliders(screen, scene, camera=None) (pygame.Surface, Scene, Camera) -> None Does nothing if show_colliders is False. Otherwise outlines every BoxCollider2D in the scene - green for solid, red for trigger - shifted by the camera's offset (if a camera was passed in) so the outlines land in the right place on screen even while the camera is scrolling.

Example

from engine.debug_manager import DebugManager

# from any file, no setup required:
DebugManager.log_info("Level loaded")
DebugManager.log_warning("Player spawned outside the level bounds", source="game_scene")
DebugManager.log_error(f"Failed to load enemy pattern: {exc!r}")

# Engine already wires these up for you; you'd only call them directly
# if you're building a custom debug key outside the default F1/F2:
engine.debug.toggle_overlay()

Part 11 - The engine shell

engine/app.py

  • Responsibility: Owns the actual pygame window, the frame clock, and the main loop. This is the only engine file main.py should ever need to import directly - everything else (scene content, gameplay scripts) goes through the Scene/GameObject/Component layers this file drives.
  • Depends on: DebugManager, SceneManager, pygame (window creation, the clock, the event loop).
  • Used by: main.py (the only place that constructs one).

The main loop, step by step

while self.running:
    raw_dt = self.clock.tick(self.fps) / 1000.0
    delta_time = min(raw_dt, self.MAX_DELTA_TIME)

    self._handle_events()
    self.debug.update(delta_time)

    if self.active_scene:
        self.active_scene.update(delta_time)

    self.screen.fill(self.background_color)
    if self.active_scene:
        self.active_scene.render(self.screen)
        self.debug.draw_colliders(...)
    self.debug.draw_overlay(...)

    pygame.display.flip()

Every frame: measure how much time passed (clock.tick), clamp it to a safe maximum, process window/keyboard events, update the debug manager's FPS counter, update the active scene (physics, input, everything), clear the screen, render the active scene, optionally draw collider outlines and the debug overlay on top, then flip the display buffer to actually show the new frame.

Why delta_time is clamped (MAX_DELTA_TIME = 0.05)

clock.tick(fps) returns the real elapsed time since the last call - if something stalls the process for a moment (a debugger breakpoint, the OS briefly pausing the window during a drag, a garbage-collection pause), the next raw_dt could be huge, e.g. a full second. Handing physics a one-second delta_time in a single step could launch a fast-moving body clean through a thin wall it would normally never tunnel through, or add a full second's worth of gravity in one jump. Clamping to 50 milliseconds means physics never takes a more dangerous step than "20 FPS worth of movement", no matter how long the real stall was - the game will visibly slow down during a stall, which is the correct, safe trade-off, rather than silently glitching through collision geometry.

What's inside

class Engine:
    MAX_DELTA_TIME = 0.05

    def __init__(self, width=1280, height=720, title="Pygame Engine",
                 fps=60, background_color=(30, 30, 35)): ...
Member Signature What it does
screen pygame.Surface The actual window surface, created by pygame.display.set_mode(...) in __init__.
clock pygame.time.Clock Used to both cap the frame rate and measure delta_time.
scene_manager SceneManager Owns whichever scenes have been loaded.
debug DebugManager The engine's diagnostics - see Part 10.
active_scene (property) Scene | None Shorthand for self.scene_manager.active_scene.
load_scene(name, scene) (str, Scene) -> Scene Registers scene under name via scene_manager.add_scene, then immediately activates it via scene_manager.set_active. The one call main.py needs for the common case of "I have one scene, make it live."
run() () -> None Blocks, running the loop above, until self.running becomes False (either the window's close button was clicked, or something called quit()). Calls pygame.quit() on the way out.
quit() () -> None Sets self.running = False, so the loop exits after finishing the current frame.

Private helper: _handle_events() - drains pygame's event queue each frame; on pygame.QUIT (the window's close button) stops the loop, and on KEYDOWN for F1/F2 toggles the debug overlay/collider outlines.

Example

from engine.app import Engine
from scenes.game_scene import build_game_scene

engine = Engine(width=1280, height=720, title="My Game", fps=60)
engine.load_scene("main", build_game_scene())
engine.run()   # blocks until the window is closed

Part 12 - Game content (not engine code)

Everything from here down is game-specific, not engine machinery - these files are free to know what a "player" or "coin" is, unlike anything under engine/.

scenes/game_scene.py

  • Responsibility: Builds the one demo level shipped with this project: a player, a floor, and a collectible coin. This is the direct replacement for what used to be hardcoded inline inside main.py.
  • Depends on: BoxCollider2D, PlayerController, Rigidbody2D, SpriteRenderer, GameObject, Scene (all engine building blocks), and CoinPickup (game-specific gameplay script).
  • Used by: main.py (calls build_game_scene() to get something to load).

What's inside

def build_game_scene() -> Scene: ...

A single function. It loads the two sprite images from assets/, generates a plain red rectangle surface for the floor (no floor art ships with this project, so one is drawn in code), then builds three GameObjects:

Object Components Notes
"Player" SpriteRenderer, Rigidbody2D, BoxCollider2D, PlayerController The collider is given an explicit size=player_img.get_size() rather than being left to auto-measure - this is the recommended pattern (see Part 5) precisely because it means the hitbox can never change shape later if you attach an Animator to the player.
"Floor" SpriteRenderer, BoxCollider2D Solid (not a trigger), no Rigidbody2D - it never moves, so it doesn't need physics, only a shape to collide against.
"Coin" SpriteRenderer, BoxCollider2D (is_trigger=True), CoinPickup The trigger collider + CoinPickup combination is what makes it collectible - see the next file.

Why this is a function that returns a Scene, rather than a scene built at import time: calling it fresh each time (build_game_scene()) gives you a brand new, independent level every time you call it - useful for restarting a level, or building multiple instances of the same kind of level layout.

Example - adding a second scene function right next to it

def build_boss_level():
    scene = Scene(name="boss")
    # ... construct a different set of GameObjects ...
    return scene

# in main.py:
engine.load_scene("boss", build_boss_level())

scripts/coin_pickup.py

  • Responsibility: A single reusable Component - deactivates its own GameObject the moment anything with a PlayerController touches its (trigger) collider.
  • Depends on: BoxCollider2D (looks up its sibling, checks is_trigger, subscribes to on_trigger_enter), Component (base class), PlayerController (used only as a type check - see below), DebugManager (setup warnings + a log line on pickup).
  • Used by: scenes/game_scene.py (attached to the "Coin" object).

Why this lives in scripts/, not engine/components/

CoinPickup is exactly as much a Component as Rigidbody2D or Animator - it's built the same way, follows the same lifecycle. The difference is generality: Rigidbody2D doesn't know or care what game you're building; CoinPickup is inherently about this game having coins. That's the entire distinction this project draws between engine/components/ and scripts/ - see the project map at the very top of this document.

Why it checks for a PlayerController instead of comparing identity to a specific "the player" object

def _handle_trigger_enter(self, trigger, other):
    if other.game_object.get_component(PlayerController) is None:
        return
    ...

This means CoinPickup never needs a direct reference to "the player" at all - anything with a PlayerController attached counts as able to collect it. If you later add a second playable character, or an NPC companion that should also be able to grab coins, this keeps working with zero changes.

What's inside

class CoinPickup(Component):
    def __init__(self, on_collect=None): ...
Member Signature What it does
on_collect callable | None Optional callback, called as on_collect(coin_game_object) the moment this coin is collected - hook in a score increment, a sound effect, a particle burst, etc., without editing this file.
start() () -> None Looks up this object's BoxCollider2D. Logs a warning (and returns early, doing nothing further) if there isn't one. Logs a different warning (but still proceeds) if the collider exists but isn't set as a trigger - since that would mean the coin physically blocks the player instead of being collectible. Subscribes _handle_trigger_enter to on_trigger_enter.
_handle_trigger_enter(trigger, other) (BoxCollider2D, BoxCollider2D) -> None The actual pickup logic: bail out if other isn't something with a PlayerController; otherwise set self.game_object.active = False (see Part 3 for what that does), log an info message, and call on_collect if one was provided.

Example - reacting to a pickup from outside the script

from scripts.coin_pickup import CoinPickup

score = {"value": 0}

def on_coin_collected(coin_object):
    score["value"] += 10
    print(f"Score: {score['value']}")

coin.add_component(CoinPickup(on_collect=on_coin_collected))

Part 13 - The entry point

main.py

  • Responsibility: Wire together an Engine, a Scene, and a Camera, then start the game. Nothing else. Per the refactor's requirement to strip game logic out of this file entirely, if you find yourself wanting to add a GameObject or a gameplay rule directly into main.py, it almost certainly belongs in scenes/ or scripts/ instead.
  • Depends on: Engine, Camera, GameObject (engine), and build_game_scene (game content).
  • Used by: nobody - this is the top of the dependency tree, the file you actually run (python main.py).

The entire file, annotated

from engine.app import Engine
from engine.components.camera import Camera
from engine.game_object import GameObject
from scenes.game_scene import build_game_scene


def main():
    engine = Engine(width=1920, height=1080, title="My Custom 2D Engine", fps=60)

    scene = build_game_scene()          # <- all game content lives over there
    engine.load_scene("main", scene)

    player = scene.find_game_object("Player")
    camera_object = GameObject(name="Main Camera")
    camera = camera_object.add_component(
        Camera(target=player, follow_speed=6.0, smooth_follow=True)
    )
    scene.add_game_object(camera_object)
    scene.set_active_camera(camera)

    engine.run()                        # <- blocks until the window closes


if __name__ == "__main__":
    main()

That's the complete file - 28 lines total. Four things happen, in order: create the engine, load a scene built elsewhere, set up a camera pointed at whatever GameObject is named "Player" in that scene, and run.

If you want to change what the demo does

  • Different level layout / different objects → edit scenes/game_scene.py, not this file.
  • Different coin/collectible behaviour → edit scripts/coin_pickup.py, not this file.
  • Different window size, title, or frame rate → the Engine(...) call right here is the one place those live.
  • Camera feels too snappy/too laggy → adjust follow_speed in the Camera(...) call right here. Want a hard-follow camera with no smoothing at all? Pass smooth_follow=False.
  • No camera at all (raw, unshifted world, exactly like the original pre-camera project) → delete the four camera-related lines, or just pass target=None.

Quick index - every function, alphabetically

A flat lookup table for "I remember the method name but not which file it's in." For full explanations, follow the link back to that file's section above.

Symbol Lives in Section
Animator.pause() engine/components/animator.py Part 6
Animator.play(name, loop, reverse) engine/components/animator.py Part 6
Animator.stop() engine/components/animator.py Part 6
BoxCollider2D.check_trigger_events(other) engine/components/box_collider2d.py Part 5
BoxCollider2D.set_size(w, h) engine/components/box_collider2d.py Part 5
BoxCollider2D.snap_left_to / snap_right_to / snap_top_to / snap_bottom_to engine/components/box_collider2d.py Part 5
Camera.get_offset(w, h) engine/components/camera.py Part 8
Camera.set_target(target, snap) engine/components/camera.py Part 8
Camera.snap_to_target() engine/components/camera.py Part 8
Camera.world_to_screen(pos, w, h) engine/components/camera.py Part 8
Component.start() / update(dt) engine/components/component.py Part 2
CoinPickup.__init__(on_collect) scripts/coin_pickup.py Part 12
DebugManager.draw_colliders(screen, scene, camera) engine/debug_manager.py Part 10
DebugManager.draw_overlay(screen, scene) engine/debug_manager.py Part 10
DebugManager.log_info / log_warning / log_error engine/debug_manager.py Part 10
DebugManager.toggle_overlay() / toggle_colliders() engine/debug_manager.py Part 10
Engine.load_scene(name, scene) engine/app.py Part 11
Engine.run() / quit() engine/app.py Part 11
GameObject.add_component(c) engine/game_object.py Part 3
GameObject.get_component(cls) / get_components(cls) engine/game_object.py Part 3
PlayerController._on_jump() (override point) engine/components/player_controller.py Part 7
Rigidbody2D.add_force(fx, fy, dt) engine/components/rigidbody2d.py Part 5
Rigidbody2D.add_impulse(ix, iy) engine/components/rigidbody2d.py Part 5
Rigidbody2D.stop() engine/components/rigidbody2d.py Part 5
Scene.add_game_object(go) / find_game_object(name) engine/scene.py Part 9
Scene.get_components(cls) engine/scene.py Part 9
SceneManager.add_scene / set_active / get_scene engine/scene_manager.py Part 9
SpriteRenderer.set_sprite(s) engine/components/sprite_renderer.py Part 4
Transform.translate(dx, dy) engine/components/transform.py Part 2
Vector2.lerp(other, t) engine/utils/vector2.py Part 1
Vector2.normalized() / length() engine/utils/vector2.py Part 1
build_game_scene() scenes/game_scene.py Part 12

Cookbook - "I want to..."

A task-first lookup, for when you know what you want to do but not which file to open.

I want to... Go to How
Add a new kind of object to the level scenes/game_scene.py Create a GameObject, add_component(...) whatever it needs, scene.add_game_object(...) it. See Part 12.
Give the player a new ability (double jump, dash, ...) new file in scripts/, or subclass PlayerController Subclass and override one of the hooks in Part 7 rather than editing player_controller.py directly.
Make the camera feel snappier or laggier main.py (or wherever you construct the Camera) Raise/lower follow_speed. See Part 8.
Turn the camera off entirely main.py Pass target=None, or skip creating a Camera at all.
Add a new collectible / trigger-based interaction new file in scripts/, modeled on coin_pickup.py Subclass Component, look up your BoxCollider2D in start(), append to on_trigger_enter. See Part 12.
Fix "my hitbox doesn't match my sprite" wherever you construct that object's BoxCollider2D Pass an explicit size=(w, h) instead of relying on auto-measurement, and double check anchor matches "topleft" (see Part 5).
Add a moving platform scenes/game_scene.py A GameObject with Rigidbody2D(is_kinematic=True) + BoxCollider2D, with something (a new small Component) setting its velocity back and forth each frame.
Make something print/log while the game runs anywhere DebugManager.log_info("...") / log_warning / log_error - see Part 10. No setup needed.
See collider outlines while playing just press F2 Built in - see Part 10.
Add a second level/scene scenes/ + main.py Write a new build_*_scene() function, then engine.load_scene("name", build_your_scene()) and scene_manager.set_active("name") to switch to it - see Part 9.
Apply a one-off knockback / continuous force (wind, current) wherever the effect happens rigidbody.add_impulse(x, y) for instant, rigidbody.add_force(x, y, delta_time) every frame for continuous - see Part 5.
Understand why the player used to jitter when landing docs/CHANGELOG.md The full, worked-through investigation, with the exact math.

About

Pygame Engine

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages