A 2D game engine built on pygame, written by hand.
Its distinguishing idea is that rendering is a sorted queue, not a surface
stack. Nothing draws to the screen directly. Every drawable object pushes a
BlitToken into a global pool keyed by depth and priority, and the renderer
flattens the whole frame into a single surface.blits() call. Layers are a
sort key. That makes a frame data before it is pixels, which is why this repo
can assert things like "41 blit tokens across 11 depths" instead of
comparing screenshots.
python main.py
is the whole entry point. It needs no PYTHONPATH and no install step.
This repository ships without art. The engine reads three image files at runtime and none of them are here — see Running it. The previous art matched the RPG Maker VX Ace RTP, which cannot be redistributed, so it was removed from the repository and its history. See docs/ASSETS.md.
Working, and honest about where it is not. ~12,350 lines of tracked Python.
| Runs | yes — headless or windowed, on pygame 2.6 / Python 3.11 |
| Tested | 16 check tools plus a frame-level regression harness |
| Stable API | no. Names are still moving. See Known rough edges |
| Docs | design plans in docs/, all reconciled against the code |
This is a personal engine being cleaned up in public, not a released library. It is usable, and reading it will teach you something about deferred rendering — but pin a commit if you build on it.
git clone https://github.com/AbstractEyes/pyoneer-engine
cd pyoneer-engine
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt # pygame, pytmxAt this point python main.py will fail, because the art is missing. It
fails with a message that says so:
PyoneerAssetMissingError: tileset image
'data/graphics/tilesets/System/TileA2.png' not found
via map='test', tmx='data/maps/test.tmx',
hint='the repository ships without art; see docs/ASSETS.md'
To get running immediately, generate placeholders:
.venv/Scripts/python.exe tools/make_placeholder_art.py
.venv/Scripts/python.exe main.pyThree checkerboard PNGs, sized to what the config and the map declare. The
engine boots and every check passes. Replace them with your own art at the
same paths whenever you like — nothing requires the original layout, because
animation frame rectangles are declared in config/animations.json.
Controls in the demo scene: WASD move, F1 toggles the test window, ←/→ rotate the player, Esc quits.
The part worth understanding before reading anything else.
entity / widget / map layer
│ core_render_blits(event)
▼
BlitPool.blit_to_layer(depth, priority, image, destination, draw_area)
│
▼
ORGANIZED_BLITS[depth][priority] → [BlitToken, ...]
│ flattened in depth → priority → insertion order
▼
surface.blits(...) ← one call, once per frame
Consequences that surprise people:
- Draw order is an integer, not a tree position. A widget nested five deep
can draw beneath the map by setting a lower depth.
GameComponent.depthaccumulates through the parent chain, so a subtree moves together. - Culling and clipping are one operation.
scripts/core/viewclip.pygivesclip_to_view(target, clip, source_origin), which returns the destination and the source sub-rect already reduced to visible pixels, orNonewhen nothing is visible. A caller cannot cull without clipping or vice versa, which is how the two used to drift apart. - Static map layers are composited. Runs of consecutive tile-only depths
are flattened into one surface, but only when it is provably lossless —
composite_is_exact()refuses a merge where partial alpha would land on partial alpha, because pygame's RGBA blitter writes the blended colour back un-normalized in that one case. Entity layers still interleave. Measured on the demo map: 6.21 ms → 3.25 ms per frame, byte-identical output. - Compositing is invalidatable.
renderer.invalidate(band)andrebake_map()exist so runtime map editing is possible; the bake is not hidden in a constructor.
Every engine object implements the same schema —
core_<domain>_<action>[_<phase>], four domains:
core_lifecycle_ |
build, prepare, prepare_pre, prepare_post, dispose, dispose_pre, dispose_post |
core_frame_ |
update, update_pre, update_post |
core_render_ |
blits |
core_input_ |
receive |
The phase is a suffix so autocomplete groups each family together.
These are not a general extension point, and this is the single most
important thing to know before subclassing. They are entry points for objects
driven from outside the component graph — scenes, layers, entities, and the
root component bound into a layer. A GameComponent reached through a parent's
components dict is driven by the event bus, which never calls them. An
override there is dead code that neither runs nor errors. In-tree components
register behaviour instead:
self.bind_sync_listener(GameEventType.UPDATE, self.__on_update)The exception: bind_component() calls core_lifecycle_prepare* and
core_lifecycle_build on the child directly, so those specific overrides do
run at bind time — which is how most of the widget tree gets built.
The engine was originally quiet by default, which is right for prototyping and wrong once someone else is reading. Now:
| raise | contract violations | scripts/core/errors.py |
| warn | unusable authored content | warnings module |
| trace | running commentary | opt-in, off by default |
Every exception is prefixed Pyoneer, so the prefix enumerates the surface:
PyoneerAssetMissingError, PyoneerEventDispatchError,
PyoneerListenerContractError, PyoneerImageMissingError,
PyoneerLayerError, PyoneerCameraMissingError, PyoneerBindTargetError, …
grouped under catchable domain bases.
Errors accumulate context as they travel up a dispatch chain, so a failure deep in a fan-out reads as a path:
map 'x' not found; available: test
via source='config/maps.json'
via component='Panel', child_slot='body'
via parent='GameWindow', child_slot='panel'
Tracing is per-subsystem and costs nothing when off:
PYONEER_DEBUG=mouse python main.py
PYONEER_DEBUG=mouse,events,render python main.py
PYONEER_DEBUG=all python main.pyChannels: mouse, keyboard, events, render, input, lifecycle,
assets. A mistyped channel name raises rather than silently producing
nothing.
data/maps/test.tmx is a Tiled map. pytmx reads
it; pytmx cannot write it. So scripts/loaders/map_document.py is a
byte-identical TMX reader/writer on xml.etree:
doc = MapDocument.load("data/maps/test.tmx")
doc.tile_layer("Floor").set_tile(4, 7, gid=65)
doc.object_layer("entity").add_object(name="chest", type="Chest", x=128, y=96)
doc.save()Load-and-save of the shipped 133,940-byte map reproduces it exactly, including its inconsistent indentation and CRLF endings, and add-object-then-remove returns the original bytes. That matters because the intent is for a human to edit in Tiled while a script edits programmatically — a writer that reflows the file makes every subsequent human diff unreadable.
.venv/Scripts/python.exe tools/check_all.py16 checks plus a frame-level drift comparison, one exit code. They are not unit tests; each one boots or drives real engine code and asserts measured behaviour — token counts, dispatch counts, frame hashes, pixel equality.
tools/smoke.py is the instrument the rest rely on. It runs N frames headless
and reports a frame hash, the component census, the blit-token histogram by
depth, culled-draw count, and listener invocations per frame. A structural
change that leaves the frame hash identical is not automatically harmless —
the same pixels can be produced by a different amount of work — so the dispatch
counters exist to catch that.
Deliberate visual changes are re-baselined explicitly:
.venv/Scripts/python.exe tools/smoke.py --frames 60 --write-baselineWithout art, 7 of the 16 checks pass; the other 9 boot the engine and need the
three image files. tools/make_placeholder_art.py is enough for all 16.
main.py entry point and the demo scene
scripts/core/ engine
game_object.py root ABC, the core_* lifecycle contract
component.py GameComponent — the UI base class (large; being split)
blitpool.py the deferred blit queue
renderer.py layers, map baking, compositing
viewclip.py containment + exact-pixel clipping
event_manager.py pygame event → PyoneerEvent
errors.py exception hierarchy
log.py opt-in trace channels
input.py action bindings, edge detection, text capture
scene/ scene graph
ui/widget/ widgets, containers, mouse/keyboard behaviours
scripts/game/ entities, animation, camera, map
scripts/loaders/ MapDocument — TMX read/write
config/ JSON: animations, entities, inputs, maps, theme
tools/ checks, smoke harness, utilities
docs/ design plans and the code review
archive/ two superseded component generations, kept for reference
Stated plainly, because most of them are recorded with measurements in
docs/:
GameComponentis a god class. ~9 responsibilities in one file. Being split incrementally;docs/IMPROVEMENT_PLAN.mdsegment 8.- Boot costs ~640 ms, up from ~230 ms, because map compositing proves its
merges are lossless with
pygame.maskwork at startup. One-time cost buying 2.6 ms per frame; pays back in ~150 frames. Not yet optimized. - Listbox does not work.
ListBoxComponenthas never run. The grid it needs now exists; what is missing is its own row selection, keyboard navigation and row template. - The scroll bar builds from the wrong formula and shifts 14 px on its first scroll event.
- No drag-and-drop.
GridComponent.snap()places by pixel position andMOUSE_DRAG_BEGIN/ENDare bindable, but nothing wires them together. - The demo map has an invisible parallax layer — its tiles sit beneath a fully opaque floor.
archive/gen3_behavior_rewrite/is an unfinished redesign that never executed. It is kept because its direction was right; seearchive/README.md.
docs/NEXT.md is the ranked list of what is actually next.
The bar is measurement. This codebase has a documented history of confident, plausible, wrong claims — including in its own docs, several of which were corrected by executing the code they described. So:
- Run
tools/check_all.pybefore and after. - If a smoke field moves, say which one, from what to what, and why. Do not re-baseline something you cannot explain.
- Prefer a raise over a fallback. A plausible wrong value is the failure mode this engine keeps producing.
- Make a test that can fail. Break the thing it covers and confirm it catches it — several assertions here passed vacuously until that was checked.
Apache License 2.0. Copyright 2023-2026 AbstractPhil.
Use it, fork it, ship a game with it, ship a closed-source game with it. The conditions are the usual Apache ones: keep the licence and copyright notice, state what you changed, and don't use the project's name to endorse yours. Apache-2.0 rather than MIT because it grants patent rights explicitly, which matters for something people build tools on top of.
The licence covers the code, not content you load into it. Art, audio and maps carry whatever licence their author gave them. No third-party art is included here — see docs/ASSETS.md and NOTICE.
pygame and pytmx are dependencies, not bundled, and are both LGPL. Linking to them from Apache-2.0 code is fine; if you redistribute a build that bundles them, their terms apply to those parts.