I’m happy to present yet the other milestone achieved in Penal Engineer.
Past 2 months were dedicated to an extensible framework of input handling: mouse and keyboard.
Apart from that (and to demonstrate the power of it), I made a whole new framework for sprites outlines (contours) dynamic generation and making them drawn on mouse hover and selection of entity.
Architecture revamp
Composition
I was building Penal Engineer as a modular project from the very beginning. However, I discovered that
quite a lot of user interaction was handled by an uber-module (.exe), while a role of .dll-s libraries
got to stay serving ECS and some gameplay. This used to block modders from potential expansion of the
ecosystem, not letting them replace parts of the game which could be granularized even more.
What I also made bad before was relying upon Microsoft DI instead of writing an own dependency layer which could serve the purpose of contract enforcing much better.
All that old architecture got finally revamped.
flowchart TB
Game(["Game<br/>Executable & composition root"])
subgraph Modules["Built-in modules"]
Base["Base"]
MainContent["MainContent"]
ModSupport["ModSupport"]
AtlasBuilder["DefaultTextureAtlasBuilder"]
ExitPrompt["GameExitPrompt<br/>(optional, disabled)"]
end
Core[["Core<br/>Runtime, contracts, ECS & services"]]
%% Runtime composition
Game ==>|loads| Base
Game ==>|loads| MainContent
Game ==>|loads| ModSupport
Game ==>|loads| AtlasBuilder
Game -.-> ExitPrompt
%% Direct project references
Game --> Core
Base --> Core
MainContent --> Base
MainContent --> Core
ModSupport --> Core
AtlasBuilder --> Core
ExitPrompt --> Core
flowchart TB
Game(["Game<br/>Executable & composition root"])
subgraph Interaction["Input & interaction plugins"]
Keyboard["DefaultKeyboardHandler"]
Mouse["DefaultMouseHandler"]
CameraControls["DefaultCameraControls"]
TargetSelector["DefaultPointerTargetSelector"]
CycleHint["DefaultPointerTargetCycleHint"]
PointerDispatch["DefaultPointerInteractionHandler"]
EntitySelection["DefaultEntitySelection"]
ContextMenu["DefaultContextMenu"]
end
subgraph Presentation["Presentation & texture infrastructure"]
DebugOverlay["DebugOverlay"]
OutlineGenerator["DefaultSpriteOutlineGenerator"]
AtlasBuilder["DefaultTextureAtlasBuilder"]
end
subgraph Content["Content & optional features"]
Base["Base"]
MainContent["MainContent"]
ModSupport["ModSupport"]
ExitPrompt["GameExitPrompt<br/>(optional, disabled)"]
end
subgraph Foundation["Shared foundations"]
KeyboardContracts[["DefaultKeyboardHandler.Contracts"]]
Core[["Core<br/>Runtime implementation"]]
Contracts{{"Contracts<br/>Shared public APIs"}}
end
%% Runtime composition
Game ==>|loads| Base
Game ==>|loads| MainContent
Game ==>|loads| ModSupport
Game ==>|loads| DebugOverlay
Game ==>|loads| AtlasBuilder
Game ==>|loads| OutlineGenerator
Game ==>|loads| Keyboard
Game ==>|loads| Mouse
Game ==>|loads| CameraControls
Game ==>|loads| TargetSelector
Game ==>|loads| CycleHint
Game ==>|loads| PointerDispatch
Game ==>|loads| EntitySelection
Game ==>|loads| ContextMenu
Game -.-> ExitPrompt
%% Other direct Game references
Game --> Core
Game --> KeyboardContracts
%% Foundation dependencies
Core --> Contracts
KeyboardContracts --> Contracts
%% Input and interaction dependencies
Keyboard --> Contracts
Keyboard --> KeyboardContracts
Mouse --> Contracts
Mouse --> Core
CameraControls --> Contracts
CameraControls --> KeyboardContracts
TargetSelector --> Contracts
TargetSelector --> KeyboardContracts
CycleHint --> Contracts
CycleHint --> KeyboardContracts
PointerDispatch --> Contracts
PointerDispatch --> Core
EntitySelection --> Contracts
EntitySelection --> Core
ContextMenu --> Core
ContextMenu --> KeyboardContracts
%% Presentation and texture dependencies
DebugOverlay --> Contracts
DebugOverlay --> Core
DebugOverlay --> KeyboardContracts
OutlineGenerator --> Contracts
OutlineGenerator --> Core
AtlasBuilder --> Contracts
AtlasBuilder --> Core
%% Content dependencies
Base --> Contracts
Base --> Core
MainContent --> Contracts
MainContent --> Core
MainContent --> Base
ModSupport --> Core
ExitPrompt --> Contracts
The main architectural change visible in v0.3 is the extraction of Contracts as the implementation-neutral
base layer, followed by the decomposition of input and interaction behavior into independently replaceable modules.
Lifecycle
In the previous version, the plugin lifecycle wasn’t thought the best, unfortunately. I used to believe that it would be enough to have the plugins ordered in a dependency graph. But I failed to realize earlier that separate phases of plugin bootstrapping are really necessary to ensure configuration schema set freeze, custom services registration freeze and so on.
Thereof I have now introduced a new plugin lifecycle, with explicit guaranteed at each phase.
| Area | v0.2 |
v0.3 |
Why it matters |
|---|---|---|---|
| Lifecycle contract | A plugin implemented one method: Initialize(ILoggerFactory, World). It returned a bool, although the host did not act on that result. |
A plugin implements Initialize, RegisterSchemas, RegisterServices, Load, and OnGameLoadContent. Failures surface through exceptions instead of an ignored success flag. |
Each kind of setup now has a clearly defined and enforceable place. |
| Execution model | The host constructed and initialized each plugin individually. One plugin completed all its work before the next plugin began. | The host keeps an ordered collection of bootstrappers and completes one phase for all plugins before beginning the next phase. | A plugin can no longer gain accidental privileges simply because it was initialized first. |
| API boundary | Plugins received the concrete Core World and commonly accessed concrete implementations through World.Services. |
Plugins receive the implementation-neutral IGameContext, whose contracts live in the separate Contracts assembly. |
Plugins can depend on stable public APIs rather than the internal shape of Core or the game host. |
| Configuration schemas | A plugin could register a schema and immediately read configuration inside Initialize. That first read could load the configuration before later plugins had registered their schemas. |
Every plugin runs RegisterSchemas first; the host then freezes path-resolver and configuration-schema registration. Configuration access is unavailable until that complete schema set has been collected. |
Enabling another configured plugin no longer makes configuration validity depend on bootstrap order. |
| Plugin services | The host built a Microsoft DI container before constructing the world. Plugins had no dedicated phase for publishing shared services. | Plugins publish typed instances or lazy singleton factories during RegisterServices. Resolution is blocked until every plugin has registered and the service catalog has been frozen. |
Replaceable facilities such as keyboard handling, pointer selection, atlas building, and outline generation can be provided behind shared contracts. |
| Service safety | Availability and conflicts largely depended on the host’s DI setup and initialization order. | Duplicate providers, invalid implementation types, premature resolution, late registration, null factories, and circular singleton construction receive explicit diagnostics. | Integration mistakes fail predictably instead of silently selecting whichever implementation happened to load first. |
| Runtime setup | Schema registration, configuration access, prefab creation, hook subscriptions, system installation, and content registration were mixed together in Initialize. |
Load runs only after schemas and services are frozen. Plugins can safely read configuration, resolve services, and register prefabs, hooks, semantic actions, bindings, or update callbacks there. |
Mod initialization becomes easier to reason about and less sensitive to incidental call order. |
| Graphics and content | There was no plugin content callback; the host performed MonoGame content and HUD setup directly. Presentation-oriented extensions had to be wired into the host or initialized too early. | OnGameLoadContent runs after fonts, rendering facilities, the texture atlas, the demo world, and other host content resources are ready. |
A debug overlay or context-menu presenter can create graphics resources without becoming part of Game. |
| Per-frame behavior | Recurring plugin behavior generally required adding an ECS system or modifying the host update loop. | Plugins can register non-ECS update callbacks with explicit priorities and unregistration tokens. The host dispatches these callbacks before the ECS world update. | Input capture, target selection, semantic-action dispatch, and UI handling can run in a deterministic order without being disguised as world simulation systems. |
| Extensibility status | The bootstrapper was effectively a one-shot content initializer. No later lifecycle participation or unloading model existed. | The new lifecycle provides clearer ownership boundaries and several token-based runtime registries, establishing groundwork for eventual unloading. Dynamic assembly discovery, dependency-graph validation, automatic cleanup, and a shutdown/unload phase are still future work. | v0.3 is a substantial plugin-platform foundation, but not yet a complete hot-loadable mod runtime. |
flowchart LR
subgraph V02["v0.2 — one-shot, sequential initialization"]
direction TB
V02Host["Host creates Microsoft DI container"]
V02World["Construct World and ConfigCoordinator"]
V02P1["Plugin A: Initialize"]
V02P2["Plugin B: Initialize"]
V02Work["Mixed bootstrap work<br/>schemas · config reads · prefabs<br/>hooks · ECS systems · textures"]
V02Content["Host performs graphics and content setup"]
V02Run["Game loop<br/>World.Update"]
V02Host --> V02World
V02World --> V02P1
V02P1 --> V02P2
V02P2 --> V02Work
V02Work --> V02Content
V02Content --> V02Run
end
subgraph V03["v0.3 — phased initialization across all plugins"]
direction TB
V03Host["Host creates Core, IGameContext<br/>and ordered bootstrapper list"]
V03Init["1. Initialize all plugins<br/>retain logger and game context"]
V03Schemas["2. RegisterSchemas on all plugins"]
V03SchemaFreeze["Freeze path resolvers and schemas<br/>configuration becomes available"]
V03Services["3. RegisterServices on all plugins<br/>instances or singleton factories"]
V03ServiceFreeze["Freeze service catalog<br/>service resolution becomes available"]
V03Load["4. Load all plugins<br/>config · prefabs · hooks · actions<br/>bindings · update callbacks"]
V03Content["Host creates graphics, atlas,<br/>demo world and content services"]
V03PluginContent["5. OnGameLoadContent on all plugins"]
V03Run["Game loop<br/>prioritized plugin callbacks<br/>then World.Update"]
V03Host --> V03Init
V03Init --> V03Schemas
V03Schemas --> V03SchemaFreeze
V03SchemaFreeze --> V03Services
V03Services --> V03ServiceFreeze
V03ServiceFreeze --> V03Load
V03Load --> V03Content
V03Content --> V03PluginContent
V03PluginContent --> V03Run
end
V02 -. "responsibilities split into explicit phases" .-> V03
Accomplishments
For players
- ✅ More consistent mouse and keyboard controls. Mouse input now distinguishes UI, entities, and empty world space, while keyboard input supports presses, releases, held keys, and modifier combinations. This provides reliable interactions without UI clicks accidentally selecting something in the world underneath.
- ✅ Configurable keybindings. Actions such as camera movement, zooming, changing floors, even cycling through hovered objects, as well as toggling debug overlay and exiting are no longer hard-wired directly into the game loop. They made out as so-called semantic actions, and their keyboard bindings can be overridden through configuration. On-screen hints display the effective keys rather than assuming the defaults. This flexibility is what many modern games lack. Mine doesn’t.
- ✅ Clear highlighting of interactable objects.
The game now generates sprite contours automatically and draws a light-blue outline around the object
currently under consideration beneath the mouse. When several objects overlap,
TabandShift+Tab(default key bindings which can be overridden by a custom configuration, as outlined in the previous point) cycle through them, with a small pointer-side hint showing both the actual configured controls and the current object number. - ✅ Persistent entity selection and camera following. Left-clicking an interactable entity selects it, gives it a distinct green outline, and makes the camera follow its position. Clicking empty world space clears both the selection and the follow target, while simply moving the pointer does not disturb the selected entity.
- ✅ Entity context menus.
Characters can now expose right-click menus whose available commands belong to that specific entity;
prisoners and guards currently demonstrate this with a sample
Move to...entry. (The command is presently a framework placeholder rather than completed movement or pathfinding, but opening, replacing, activating, and dismissing menus — including withEscape— is fully handled.)
For modders
- ✅ Replaceable input-handling modules. Mouse observation, pointer-target selection, pointer-event dispatch, keyboard recognition, and camera controls have been separated into plugin-style services with explicit update ordering. A mod can consume the shared pointer snapshot, register new behavior, or replace a default provider without depending on the MonoGame host or rewriting unrelated input systems.
- ✅ Declarative mouse interaction for ECS entities.
PointerTargetcomponent defines whether and where an entity can be hit, whileMouseInteractionmaps left-click, right-click, pointer-enter, and pointer-leave events to semantic action IDs. This lets a mod give a door anOpenaction or a machine anInspectaction through prefab/component data, while empty-space actions are supported without fabricating invisible ECS entities. - ✅ Extensible semantic actions and keybindings.
Operations now have stable namespaced IDs, typed contexts, discovery metadata, lifecycle-aware registration,
and explicit
Handled,Ignored, orFailedresults, allowing the same action to be invoked from a key, mouse binding, or context-menu item. Mods can provide default bindings in namespaced keyboard contexts, including pressed, released, and continuously held chords, while player overrides remain dormant rather than being discarded if the corresponding mod is temporarily absent. - ✅ Runtime-generated sprite contours. The new replaceable outline generator can derive inset or outset contours from any RGBA sprite, with configurable color, thickness, and alpha threshold. A content mod can therefore obtain hover and selection artwork automatically — or supply hand-drawn alternatives — and versioned texture dependencies ensure that only affected generated atlas entries need rebuilding.
- ✅ Composable entity context menus.
The new
ContextMenuECS component stores ordered data-only entries containing stable item IDs, captions, semantic action IDs, and enabled states; mods can add, remove, or replace those entries at runtime. An action receives both the owning entity and selected item ID, making additions such asInspect,Assign,Arrest, orRepairpossible without embedding callbacks or UI objects inside prefab data. - ✅ Safer extensible UI interaction. Context-menu dismissal uses the real pointer snapshot and explicit click consumption, with no invisible full-screen backdrops or temporary world entities. As a result, a mod-provided popup can close on an outside click without that same click leaking through and selecting an entity or triggering an empty-world action.
Demo video
What’s next?
I’m now facing the funniest part of the development: to make entities live.
I’m going to introduce the actual movement by Move to... context menu item call first. I’ll build
the necessary ECS systems and game clock integrator.
The initial endeavour is to have an entity moving directly, ignoring obstacles and walls. Pathfinding is a later step.
Stay tuned — now you can do so with RSS! (Top-right corner of the page is up to serve your RSS reader.)