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.
- The big picture
- Project map
- Part 1 - Foundations:
engine/utils/ - Part 2 - The component system itself
- Part 3 -
GameObject - Part 4 - Rendering
- Part 5 - Physics and collision
- Part 6 - Animation
- Part 7 - Input and movement
- Part 8 - The camera
- Part 9 - Managing the world
- Part 10 - Diagnostics
- Part 11 - The engine shell
- Part 12 - Game content (not engine code)
- Part 13 - The entry point
- Quick index - every function, alphabetically
- Cookbook - "I want to..."
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."
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.
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/.
- Responsibility: A minimal 2D vector type, used anywhere the engine
would otherwise have to pass around two separate
x/yfloats 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).
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.
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.
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)- Responsibility: Defines one custom warning category,
EngineWarning, used for catching misconfiguration at construction time - the moment you writeBoxCollider2D(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(unknownmovement_type).
There are two different kinds of "something's wrong" in this engine, and they're handled two different ways on purpose:
- 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-inwarningsmodule 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. - 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.
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).
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)- Responsibility: The base class every single behaviour in the engine
inherits from. Defines the lifecycle (
start()/update()) and theupdate_ordermechanism that keeps that lifecycle predictable. - Depends on: nothing.
- Used by: literally every other file under
engine/components/, plusTransformand every gameplay script.
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.
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". |
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))- 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(forpositionandscale). - Used by:
GameObject(creates one automatically), and read/written by nearly every other component (Rigidbody2D,BoxCollider2D,SpriteRenderer,Camera,PlayerController,Scene.render).
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.
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.
# 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- 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 viaself.game_object.
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).
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.
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- 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()(readssprite,z_index,offset_y,enabled),Animator(writesspriteevery time the frame advances),BoxCollider2D(readssprite.get_size()once, if you don't pass an explicit collider size).
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.
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).
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 = FalseThese 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.
- 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 callscheck_trigger_events),DebugManager(draws every collider's outline when the F2 overlay is on), gameplay scripts likeCoinPickup(checksis_trigger, subscribes toon_trigger_enter).
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.
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.
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).
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)- 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
BoxCollider2Din 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/writesvelocityandis_grounded),scenes/game_scene.py(attaches one to the player).
Every frame, in this order:
- If
use_gravity, addgravity * gravity_scale * delta_timeto downward velocity (this is what makes falling accelerate over time, rather than falling at a constant speed). - Clamp downward velocity to
terminal_velocity(a falling body can't speed up forever). - Apply
dragto horizontal velocity (a damping factor - higherdragmeans horizontal movement slows down faster once you stop pressing a direction). - Move on the X axis, then immediately check + resolve X collisions.
- 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).
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).
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- Responsibility: Cycles a
SpriteRenderer'sspritethrough a list of frames over time, based on a dictionary of named animations. - Depends on:
Component,SpriteRenderer(writes.spritedirectly every time the frame advances). - Used by:
PlayerController(callsplay(...)every frame with whichever animation matches the current movement state).
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).
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.Surfaceframes.
| 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).
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)- 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 anAnimatoris present. - Depends on:
Component,AnimatorandRigidbody2D(both looked up, optionally - this component works even without either, see below),DebugManager(one-time setup warning),EngineWarning(badmovement_type), andpygamedirectly (readspygame.key.get_pressed()and usespygame.K_*constants for default keybinds). - Used by:
scenes/game_scene.py(attaches one to the player).
"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 setsRigidbody2D.velocitydirectly (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 aRigidbody2Dto do anything useful (jumping setsrigidbody.velocity.ydirectly), and adds two well-known "game feel" techniques on top of the bare minimum - see below.
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_groundedwasn'tTrueyet 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().
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.
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).
from engine.components.player_controller import PlayerController
player.add_component(PlayerController(
speed=200,
jump_force=500,
movement_type="platformer",
))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- 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 librarymath(for the exponential smoothing formula). - Used by:
Scene.render()(callsget_offset()),DebugManager.draw_colliders()(same, so collider outlines line up with the shifted world),main.py(creates and configures one).
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.
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(...). |
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.
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."
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)- 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()(callsupdate()/render()every frame viaSceneManager),scenes/game_scene.py(creates and populates one).
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.
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.
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)- Responsibility: Owns every
Sceneyou'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).
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.
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. |
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")- 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 librarytimeandcollections.deque. - Used by:
Engine(creates one, callsupdate()/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,CoinPickupall callDebugManager.log_warning(...)or similar without needing a reference passed in (see "two ways to log" below).
DebugManager.log_warning("...") # from anywhere - no reference needed
engine.debug.log_warning("...") # via the specific instance Engine createdBoth 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.
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.
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. |
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()- Responsibility: Owns the actual pygame window, the frame clock, and
the main loop. This is the only engine file
main.pyshould ever need to import directly - everything else (scene content, gameplay scripts) goes through theScene/GameObject/Componentlayers 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).
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.
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.
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.
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 closedEverything 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/.
- 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), andCoinPickup(game-specific gameplay script). - Used by:
main.py(callsbuild_game_scene()to get something to load).
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.
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())- Responsibility: A single reusable
Component- deactivates its ownGameObjectthe moment anything with aPlayerControllertouches its (trigger) collider. - Depends on:
BoxCollider2D(looks up its sibling, checksis_trigger, subscribes toon_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).
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.
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. |
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))- Responsibility: Wire together an
Engine, aScene, and aCamera, 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 aGameObjector a gameplay rule directly intomain.py, it almost certainly belongs inscenes/orscripts/instead. - Depends on:
Engine,Camera,GameObject(engine), andbuild_game_scene(game content). - Used by: nobody - this is the top of the dependency tree, the file
you actually run (
python main.py).
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.
- 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_speedin theCamera(...)call right here. Want a hard-follow camera with no smoothing at all? Passsmooth_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.
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 |
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. |