pgrpg is a Pygame-based 2D RPG game engine built around an Entity–Component–System (ECS) architecture with strong emphasis on data-driven design, behavior trees, and event-driven gameplay. Game logic, AI behavior, scenes, processors, and even UI flows are primarily defined in JSON/YAML — enabling rapid iteration without touching engine code.
The engine is designed for experimentation with RPG mechanics, AI orchestration, and systemic gameplay rather than as a monolithic framework.
Learn by doing! Documentation is still growing. The best way to understand the engine is to browse the numbered test scenes under
example_game/resources/scenes/tests/, run them, read the JSON, and tweak things. There are comments throughout to guide you.
All of this grew from personal experimentation with Python and the ECS paradigm. Clarity and readability were prioritized over raw performance so the code stays approachable.
| Feature | Description |
|---|---|
| ECS | Entity–Component–System powered by a custom esper fork |
| AI | Behavior Trees (BTrees), ordered command lists (BLists), and Brain command queues |
| Events | Event-driven architecture — conditions and actions defined in JSON |
| Data-driven | Scenes, entities, processors, handlers all in JSON/YAML with C-style comments |
| Processor groups | Named processor groups activated per game state (e.g. separate inventory loop) |
| Non-blocking pathfinding | BFS pathfinding distributed across game cycles |
| Combat systems | Modular damage, health, score, destruction, arming/disarming |
| Inventory | Drag-and-drop inventory with configurable slot layout |
| FX systems | Sound and visual effects bound to component lifecycle events |
| Cameras | Multiple cameras, split-screen, scroll, delayed-scroll |
| Dialogs | Scriptable dialog flows |
| Dev console | In-game F9 console with commands and .scr scripts |
| JSON schemas | JSON Schema (Draft-07) definitions for all components, commands, and processors |
# Clone the repository
git clone <repo-url>
cd pgRPG
# Install the engine package and dependencies
pip install -e .
# Run the example game (defaults to sokoban)
python example_game/game.py
# Run a specific test scene
python example_game/game.py --file tests/01_movements/test_movement_01.jsonc
# Start directly into the main menu
python example_game/game.py --state MAIN_MENU
# Start in the dev console (no scene file = console entry)
python example_game/game.pyThe in-game dev console (toggle with F9) lets you inspect entities, components, and processor timings, and run .scr scripts for ad-hoc control.
pgRPG/
├── pgrpg/ Engine package (installable via pyproject.toml)
│ └── core/
│ ├── main.py Entry point: init() + game loop
│ ├── engine.py Scene loading pipeline + manager wiring
│ ├── scene.py Lightweight Scene data object
│ ├── ecs/
│ │ └── __init__.py Custom esper fork: World, Component, Processor
│ ├── managers/
│ │ ├── ecs_manager.py ECS world wrapper (processors/templates/entities)
│ │ ├── event_manager.py Event queue + JSON-defined handler dispatch
│ │ ├── script_manager.py Lazy-loads Python script modules; executes json_logic trees
│ │ ├── command_manager.py Entity command queue (Brain/BTree/BList-generated)
│ │ ├── map_manager.py Tiled tile map loading and management
│ │ ├── dialog_manager.py Scriptable dialog flow definitions
│ │ ├── message_manager.py In-game message log
│ │ └── pathfind_manager.py Non-blocking pathfinding (BFS, distributed)
│ ├── config/
│ │ ├── gui.py Pygame display/GUI manager (module-level singleton)
│ │ ├── sound.py Sound/music manager (module-level singleton)
│ │ └── states.py State enum + state machine
│ ├── commands/generators/
│ │ ├── btree/ Behavior Tree command generator
│ │ └── blist/ Ordered command list generator
│ └── functions/ Pure utility functions
│ ├── get_dict_from_file.py JSON/YAML loader with C-style comment support
│ ├── json_logic.py JSON-encoded condition/action tree evaluator
│ ├── translate.py Alias string → integer ECS ID substitution
│ └── get_dict_params.py Template variable substitution ($var)
│
└── example_game/ Reference game built on pgrpg (not part of the package)
├── game.py Runnable entry point
├── config.jsonc Game-specific config (overrides pgrpg defaults)
└── core/
├── components/ Game-specific Component classes
├── processors/ Game-specific Processor classes (20 system subdirs)
│ ├── animation_system/
│ ├── arm_ammo_system/
│ ├── arm_weapon_system/
│ ├── attack_system/
│ ├── collision_system/
│ ├── command_system/
│ ├── damage_system/
│ ├── debug_system/
│ ├── destroy_system/
│ ├── drop_system/
│ ├── effects_system/
│ ├── event_system/
│ ├── factory_system/
│ ├── gui_system/
│ ├── movement_system/
│ ├── pickup_system/
│ ├── position_system/
│ ├── render_system/
│ ├── score_system/
│ ├── sensor_system/
│ └── teleport_system/
├── scripts/ Event action scripts (lazy-loaded by ScriptManager)
├── commands/ Command implementations for CommandManager
├── states/ State module implementations (main menu, pause, …)
├── console/ Dev console commands and scripts
└── schemas/ JSON Schema definitions
├── scene.schema.json
├── entity.schema.json
├── template.schema.json
├── component.schema.json ← top-level component dispatcher
├── command.schema.json ← top-level command dispatcher
├── processor.schema.json ← top-level processor dispatcher
├── definitions.schema.json ← shared type definitions
├── components/ One schema per Component class
├── commands/ One schema per command
└── processors/ One schema per Processor (grouped by system)
└── resources/
├── scenes/ Scene definition files (.jsonc / .yaml)
│ ├── empty.jsonc Minimal scene template
│ ├── tests/ Numbered progressive test scenes (00–12)
│ └── games/ Complete game scenes (sokoban, collect_coins, kill_all)
├── entities/ Reusable entity definition files
├── maps/ Tiled map files (.tmx)
├── btrees/ Behavior tree definition files
├── dialogs/ Dialog flow definition files
└── images/, sounds/, music/, fonts/, frames/, models/
All managers in pgrpg/core/managers/ are modules, not class instances. There is no dependency-injection container — the engine wires them by passing a game_functions dict to ecs_manager.initialize() at startup. Functions from multiple managers are collected into this dict and injected into every Processor.
# engine.py — wiring example (excerpt)
ecs_manager.initialize(game_functions={
"FNC_ADD_EVENT": event_manager.add_event,
"FNC_GET_MAP": map_manager.get_map,
"FNC_ADD_COMMAND": command_manager.add_command,
...
})pgrpg/core/config/states.py holds the State enum. Each state (MAIN_MENU, GAME, CONSOLE, PAUSE_GAME, …) maps to a state module with a run(key_events, key_pressed, dt) method. state_manager.change_state() switches between them each frame.
pgrpg/core/ecs/__init__.py is a modified esper 1.3 with added capabilities:
| Addition | Purpose |
|---|---|
| Processor groups | Processors belong to named groups (e.g. "default", "inventory") and can be ticked independently |
| Execution throttling | exec_cycle_step=N makes a Processor run every N cycles instead of every frame |
| Extended queries | get_components_ex / get_components_exs / get_components_opt for include/exclude/optional filtering |
SkipProcessorExecution |
Exception raised inside super().process() to skip a Processor's logic cleanly |
Component — inherit pgrpg.core.ecs.Component, use __slots__ for memory efficiency. Optionally implement reinit() for display config changes and pre_save() / post_load() for serialization.
Processor — inherit pgrpg.core.ecs.Processor, implement process(**kwargs). Always call super().process() first to handle cycle throttling.
engine.load_scene_from_def() walks an ordered list of [json_path, handler_fn] pairs:
prereqs → recursively load dependency scenes first
cleanup/processors → remove processors matching UNIX wildcard pattern
cleanup/maps → remove maps matching pattern
cleanup/templates → remove templates matching pattern
cleanup/entities → remove entities matching pattern
cleanup/dialogs → remove dialogs matching pattern
cleanup/handlers → remove handlers matching pattern
processors → register Processors into the ECS World
maps → load Tiled maps
dialogs → load dialog flow definitions
templates → load entity templates (parameterized with $vars)
entities (pass 1) → register all entities first so aliases exist
entities (pass 2) → fill entities with components (aliases now resolve)
entities/…/handlers → load handlers embedded inside component params
handlers → load top-level scene event handlers
The two-pass entity loading ensures aliases registered later in the file are available when components of earlier entities reference them.
pygame events + keys
└─▶ state_module.run()
└─▶ ecs_manager.process() — all Processors tick in priority order
│
├─ Processors emit:
│ event_manager.add_event() (game events: collision, damage, …)
│ command_manager.add_command() (entity commands from AI/input)
│
├─▶ command_manager.process_commands()
│ └─▶ command.process() — move, attack, wait, …
│
└─▶ event_manager.process_events()
└─▶ handler(s) from scene JSON
└─▶ script_manager.execute_event_actions()
├─ translate(alias → entity_id)
└─ json_logic(actions)
└─▶ execute_script(name) — lazy-loaded Python module
└─▶ gui_manager.flip()
Entities have string aliases in JSON (e.g. "player", "enemy_1"). Before any script executes, script_manager calls translate() to replace those strings with integer ECS entity IDs. This means all aliases must be registered before handlers fire — which the two-pass entity loading guarantees.
Game events flow through event_manager. Processors enqueue events via add_event(); the GameEventsExProcessor drains the queue each frame by calling process_events(). Each event type can have multiple handlers defined in scene JSON. The process and ignore parameters allow a handler instance to selectively act on only certain event types.
Key event types used internally: SCENE_START, ITEM_PICK, ITEM_DROP, ENTITY_COLLIDE, ENTITY_DAMAGE, ENTITY_DESTROY, ENTITY_SCORE, TELEPORT.
Commands are the unit of entity behavior. Each command is a Python class with a process() method returning SUCCESS, FAILURE, or RUNNING. Commands are generated each frame by:
| Generator | Source |
|---|---|
GenerateCommandFromInputProcessor |
Keyboard/mouse input via Controllable component |
GenerateCommandFromBrainProcessor |
Brain component command list (sequential AI) |
GenerateCommandFromBTreeProcessor |
Behavior Tree (BTreeAI component) |
GenerateCommandFromBListProcessor |
Ordered command list (BListAI component) |
GenerateCommandFromFileProcessor |
Recorded command file (playback) |
Two config files are merged at startup:
pgrpg/core/config/default.jsonc— engine defaults (do not edit directly)<game>/config.jsonc— game overrides
| Section | Purpose |
|---|---|
DISPLAY |
Resolution, FPS, fullscreen, window title, font settings |
FILEPATHS |
GAME_PATH, pgrpg_PATH, and all resource sub-paths |
MODULEPATHS |
Python module paths for components, processors, scripts, commands, states, console |
KEYS |
Key bindings |
GAME |
Game-specific constants (speed, tile size, timers) |
MODULEPATHS controls where the engine looks for game code:
Scene files are .jsonc (JSON with C-style // comments) or .yaml. All paths are relative to FILEPATHS.SCENE_PATH.
{
"$schema": "../../core/schemas/scene.schema.json", // enables IDE validation
"id": "my_scene",
"title": "My Scene",
"description": "What this scene is about.",
"objective": "What the player should do.",
// Scenes to fully load before this one
"prereqs": ["common/base_scene.jsonc"],
// Remove previously loaded objects (UNIX wildcards supported)
"cleanup": {
"processors": ["my_old_proc*"],
"maps": ["old_map*"],
"templates": [],
"entities": ["enemy_*"],
"dialogs": [],
"handlers": ["old_handler_*"]
},
// ["module.ClassName", {params}] ← default group
// ["group_id", "module.ClassName", {params}] ← named group
"processors": [
["render_system.perform_render_map_processor:PerformRenderMapProcessor", {}],
["render_system.perform_render_model_processor:PerformRenderModelProcessor", {}],
["inventory", "render_system.perform_render_inventory_processor:PerformRenderInventoryProcessor", {}]
],
"maps": [
{"id": "test_map", "file": "test_map.tmx"}
],
"templates": [
{
"id": "t_tile_pos",
"vars": ["$tileX", "$tileY", "$map"],
"components": [
{"type": "position:Position", "params": {"tile_x": "$tileX", "tile_y": "$tileY", "map": "$map"}}
]
}
],
"entities": [
{
"id": "player",
"components": [
{"type": "position:Position", "params": {"tile_x": 5, "tile_y": 5, "map": "test_map"}},
{"type": "movable:Movable", "params": {"speed": 128}},
{"type": "controllable:Controllable", "params": {
"control_cmds": {
"up": [["move_dir", {"moves": [["up", 64]]}]],
"down": [["move_dir", {"moves": [["down", 64]]}]],
"left": [["move_dir", {"moves": [["left", 64]]}]],
"right": [["move_dir", {"moves": [["right", 64]]}]]
},
"key_feedback": {"up": "move_up", "down": "move_down", "left": "move_left", "right": "move_right"}
}},
{"type": "renderable_model:RenderableModel", "params": {"model": "dark_male.json"}}
]
}
],
// [event_type, {id, actions}]
"handlers": [
["SCENE_START", {
"id": "on_start",
"actions": ["SCRIPT", "show_msg_window", {"html_text": "Welcome to <b>My Scene</b>!"}]
}],
["ENTITY_COLLIDE", {
"id": "on_collide",
"actions": ["SCRIPT", "some_collision_script", {}]
}]
]
}Templates are reusable entity blueprints with $var placeholders. They can be referenced from other entity definitions:
// Using a template — string form
"templates": ["t_tile_pos(3, 7, test_map)"]
// Using a template — list form (supports complex param objects)
"templates": [["t_tile_pos", {"$tileX": 3, "$tileY": 7, "$map": "test_map"}]]# example_game/core/components/my_component.py
from pgrpg.core.ecs import Component
class MyComponent(Component):
__slots__ = ['value', 'active'] # required — enables __str__ and memory efficiency
def __init__(self, value: int = 0, active: bool = True):
self.value = value
self.active = active
def reinit(self):
pass # called on display config change (resolution, fullscreen)Reference in scene JSON:
{"type": "my_component:MyComponent", "params": {"value": 42}}The type string format is module_stem:ClassName, resolved relative to MODULEPATHS.COMPONENT_MODULE_PATH.
# example_game/core/processors/my_system/my_processor.py
from pgrpg.core.ecs import Processor, SkipProcessorExecution
import example_game.core.components as c
class MyProcessor(Processor):
def __init__(self, game_functions: dict, **kwargs):
super().__init__(**kwargs)
# Pull only what you need from game_functions
self._add_event = game_functions["FNC_ADD_EVENT"]
def process(self, events, keys, dt, **kwargs):
super().process() # handles exec_cycle_step throttling; may raise SkipProcessorExecution
for ent, (pos, mov) in self.world.get_components(c.Position, c.Movable):
# do work — query components, add events, add commands
passReference in scene JSON (note: module.submodule:ClassName path is relative to MODULEPATHS.PROCESSOR_MODULE_PATH):
["my_system.my_processor:MyProcessor", {"exec_cycle_step": 2}]
// or with a named group:
["inventory", "my_system.my_processor:MyProcessor", {}]Common game_functions keys available in processors:
| Key | Purpose |
|---|---|
FNC_ADD_EVENT |
Enqueue a game event |
FNC_GET_MAP |
Get a loaded Tiled map by name |
FNC_ADD_COMMAND |
Add a command to the command queue |
FNC_GET_ENTITY_ID |
Resolve alias → entity integer ID |
FNC_PLAY_SOUND |
Play a sound effect |
FNC_REQUEST_PATHFIND |
Request a pathfinding calculation |
FNC_GET_PATH |
Retrieve a completed path |
REF_ECS_MNG |
Direct reference to the ECS manager module |
Scripts are Python modules lazy-loaded by ScriptManager. Each must expose an initialize(register, name) function that registers the handler:
# example_game/core/scripts/my_script.py
import logging
logger = logging.getLogger(__name__)
def initialize(register: callable, name: str) -> None:
register(_run, name)
def _run(event, **kwargs):
# event.event_type — string event type
# event.params — dict of event parameters
# **kwargs — action arguments from scene JSON
logger.info("my_script called for event %s", event.event_type)Reference in handler JSON:
"actions": ["SCRIPT", "my_script", {"arg1": "value", "arg2": 42}]Scripts can use json_logic conditions to branch behavior and can enqueue further events or commands by importing manager modules directly.
Entities with a Brain component hold a simple command list. Each entry is a 3-element tuple:
{"type": "brain:Brain", "params": {
"commands": [
[null, "wait", {"ms": 500}], // index 0 — wait, jump to null on fail (stop)
[0, "move_to", {"pos": [5, 3]}], // index 1 — move; on fail jump back to index 0
[null, "reset_brain", {}] // index 2 — restart the sequence
]
}}Format: [exception_goto_index | null, "command_name", {params}]
The Brain processor advances the index each frame, reads SUCCESS/FAILURE/RUNNING from the command, and moves forward or jumps to the exception index.
More complex AI uses BTreeAI component pointing to a .json behavior tree file:
{"type": "btree_ai:BTreeAI", "params": {"btree": "enemy_patrol.json"}}Behavior trees are defined under resources/btrees/. Supported node types include Sequence, Selector, Repeater, Inverter, and leaf nodes that execute game commands.
Sensor commands for BTree conditions:
test_can_see— check if target entities are in the sight conetest_can_hear— check if target entities are within earshottest_damaged— check if this entity was recently damagedtest_bb_value— evaluate a json_logic expression against the blackboard
BTree and BList processors maintain a blackboard — a key/value dict per entity. Commands can write to it (set_bb_value) and read from it using the ^key pointer syntax:
["move_to_pos_target", {"target": "^last_seen_enemy"}]The test scenes under example_game/resources/scenes/tests/ are numbered and progressive. Each demonstrates one or two engine features in isolation:
| Folder | Topics covered |
|---|---|
00_render/ |
Basic rendering, models, animation |
01_movements/ |
Movement commands, direction facing |
02_collisions/ |
Entity-entity and entity-map collision |
03_camera/ |
Camera follow, scroll, delayed scroll |
04_projectiles/ |
Weapon arming, projectile factories, ammo |
05_damage/ |
Damage system, health, destruction |
06_sound/ |
Sound FX on events, music playback |
07_teleport/ |
Teleport component and trigger zones |
08_score/ |
Score generation on pickup/damage/kill |
09_pickup/ |
Pickup system, inventory basics |
10_sensors/ |
Sight cone, earshot, sensor-driven AI |
11_sensors/ |
Advanced sensor use with delayed camera |
12_ai/ |
Behavior trees, Brain, parallel commands |
The complete games under resources/scenes/games/ show full integration:
| Game | Description |
|---|---|
sokoban/ |
Puzzle — push boxes onto target tiles; uses wall entities for physics |
collect_coins/ |
Action — collect coins before time runs out |
kill_all/ |
Action — defeat all enemies using AI-controlled NPCs |
# Python unit tests
pytest tests/
# Doctest on individual component modules
python -m example_game.core.components.brain -vTasks specifically targeting Claude Code assistance:
- Improve performance of MAP rendering — redo with caching
- Review
GenerateCollisionsOptimizedProcessorfor performance - Review
PerformFrameUpdateProcessorfor performance - Review
PerformIdleAnimationProcessorfor performance - Prepare pytest tests for pgrpg functions/classes
- Prepare complex Behavior Tree logic for NPC — described as text, output as JSON
- Prepare a series of JSON scene files using human-language descriptions
- Porting to browser/mobile
- Prepare
kill_allscenario scene with 4 enemies using existing commands - Fix playback speed to be computer-speed independent
- Discuss architecture change to client/server model (multiplayer)
- Suggest solution for game string translation
-
Positioncomponentx/ycan be float — fix to int only -
move_tocommand — NPCs walk through tiles (pathfinding ignores map collisions) -
move_to_pos_target_vect— entity facing direction not updated during movement - Pushing entities into walls still possible despite map collision being enabled
- Debug processor only works with one camera
- Debug info hover raises render error
- When restarting
collect_coins, loading screen shows in background - Some problem in
tests/12_ai/simple/do_parallel.jsoncwhen enemy approaches -
event_manager._event_queuecan grow unbounded during heavy collision frames — needs max capacity or event TTL
Engine / Architecture
- Move Flag-removal processors before Flag-generator processors in the default ordering
- Implement post-requisite checks for processors (after all loaded, verify dependencies)
- Named processor group:
ALLcleanup option - Consider client/server architecture for multiplayer
- Refactor scripts to import
ecs_managerdirectly instead ofmain -
gui.pyrefactor — GUIContext to represent window/manager/etc. - Console height dynamically calculated from config
- Settings screen with Apply button (UIWindow checkbox exception needs fix)
- Universal asset loader (full path / partial / no suffix for models, sounds, VFX)
- Console commands support UNIX-like patterns
- Console
//comment support in.scrscripts -
translations— JSON key:value file switchable by config:trans("Some_text") - Font config automation — currently named one by one
- State screens with layout based on resolution config
-
for state screens prepare layout based on resolution config
Gameplay systems
- Throw items out of inventory (with
HasInventory.remove()in dict_utils) - Show item information in inventory footer
- How to prevent a just-dropped item from being immediately re-picked
-
implement wear processors - Messages should display on camera, not window
- Enter/Esc to confirm/dismiss exit dialog
- Weapon/ammo drop when armed — fix
RenderDataFromParentcleanup onWeaponInUseremoval - Shaders (reference: DaFluffyPotato)
- Inventory weaponry slots (separate from general inventory)
-
redo rendering of the tileset— incremental rendering using previous frame data - Extend sound FX —
SoundFXOnGenerationfor projectiles; multi-FX per entity - Visual FX —
VisualFXOnGeneration,VisualFXOnCreation - AStar pathfinding option alongside BFS; DFS option
- BFS pathfinding preference for axis-aligned movement (reduce diagonal bias)
-
do_parallel— support skipping cycles (timed sub-commands) - New commands:
test_bb_value_in,test_bb_value_not_in(faster json_logic alternatives) - Blackboard implicit
selfkey (entity ID/name available to all handlers) - New command:
do_if_bb_test_true - BTree/BList restart function (tree restarts on event)
- Test recording — verify that inventory commands are captured
Developer experience
-
tile_to_px/px_to_tileutility stored universally -
dict_utilsas standalone package -
main.reinit()— more universal, selective reinit - Make state-change checks optional (warnings only, not hard errors)
-
every game/test should live outside pgrpg/— clean separation - Abstract manager interface (
clear(),register()) for uniform iteration and progress bar - Logging: add
%-style formatting throughout (avoid f-string construction when log level is off)
-
GameEventsExProcessoroptimized —collections.dequereplaces list (O(1) popleft vs O(n) pop(0));ignore/processfilters converted to sets for O(1) membership tests -
--fileand--stateCLI parameters for targeted scene/state startup -
PerformScrollDelayedCameraProcessor— smooth delayed camera follow withdelayparam - Music playback via
play_musicscript triggered by scene events - Wall entities replace map-collision tiles — fixes box-chain physics (used in sokoban)
- UNIX wildcard cleanup patterns for processors, maps, templates, entities, dialogs, handlers
-
arm_ammocommand + disarm ammo processors + full arming flow -
load_from_templatecommand — loads any template onto an existing entity -
toggle_controlscommand — switches full key mapping (for inventory mode) - Inventory system — slots, drag, move with arrow keys, visual feedback
- Processor groups — separate processor loops per game state (e.g. inventory)
- JSON Schema (Draft-07) for all components, commands, and processors
- Template variables (
$var) in component params — validated by schema - Behavior tree command generators (BTree + BList)
- Non-blocking BFS pathfinding with checkpoint support
- Score system (pickup, damage, no-health events)
- Teleport system with key-entity trigger
- Drop system with free-area search algorithm
- In-game F9 dev console with
proc_perf,get_components,get_processors,.scrscripts - Progress bar on scene load (configurable per scene)
- Custom esper fork with processor groups, throttling, extended queries
- Two-pass entity loading (register aliases first, fill components second)
- GitHub Actions: pytest, doctest, lint


