Visita Interiora Terrae Rectificando Invenies Occultum Lapidem — "Visit the interior parts of the Earth; by rectification thou shalt find the Hidden Stone." The initials spell VITRIOL, the engine's name. It's a quiet nod to the engine's lineage: an earlier iteration was an alchemy-themed engine called Alkahest, and Vitriol is the rewrite that folds in the lessons from that work.
A 2D game engine written in Rust on top of OpenGL 4.5, built as a workspace of
small vtrl_* crates that compose through two seams: a Plugin trait that
hands a plugin a &mut World and &mut AssetManager at startup, and a global
message bus that every system can publish to or subscribe to without taking a
direct dependency on the producer.
It currently runs on glfw + gl (OpenGL 4.5 core) on desktop
Linux/Windows/macOS. OpenGL is the starting point, not the destination — the
renderer is structured to be peeled off into a backend-agnostic layer later.
The engine is single-threaded on the main loop and uses an ECS (vtrl_ecs)
with a fixed 16-stage schedule modeled after Bevy.
This is a personal engine, built for a specific game project and as a vehicle for learning the low-level / high-performance side of engine work. It is open source and others are free to use it, but it isn't designed around an external audience and the API will change without notice when something better suggests itself.
- Composition over inheritance. Everything the engine does — windowing,
rendering, input, scripting, tilemaps, the debug overlay — is a plugin that
registers systems on a shared
World.App::with_default_plugins()is just an ordered list ofinsertcalls; users can drop plugins, add their own, or reorder by composingwith_pluginmanually. - Loose coupling through messages. Cross-cutting communication (window events, shutdown, input state updates) flows through a single global bus. Plugins don't hold references to each other; they emit and consume messages.
- Crate boundaries enforce the architecture. Each subsystem lives in its
own crate (
vtrl_window,vtrl_render,vtrl_scene, etc.) so it cannot reach into another's internals — only into the shared types invtrl_commonandvtrl_ecs.
flowchart TB
subgraph App["App (vtrl_core)"]
PS["PluginStorage<br/>insertion-ordered Vec"]
World[("World<br/>ECS + Schedule + Resources")]
AM[("AssetManager<br/>+ AssetRegistry")]
Loop["16-stage Schedule<br/>PreInit → ... → PostShutdown"]
end
subgraph Bus["Global Message Bus (vtrl_common)"]
direction LR
Chan["flume mpmc channel<br/>Envelope = Message + timestamp"]
Handlers["Handlers: Generic or Typed(TypeId)"]
end
subgraph Plugins["Plugins"]
WP[WindowPlugin]
RP[RenderPlugin]
IP[InputPlugin]
SP[SceneManagerPlugin]
TP[TilemapPlugin]
EP[EntityScriptingPlugin]
CP[CollisionPlugin]
DP[DebugOverlayPlugin]
UP[User plugins]
end
subgraph Inv["inventory registries"]
CR[ComponentRegistration]
AR[AssetRegistration]
SR[ScriptableRegistration]
end
PS -- "build(&mut World, &mut AssetManager)" --> Plugins
Plugins -- "add_system / add_resource" --> World
Plugins -- "register_handler" --> Handlers
Plugins -- "send(msg)" --> Chan
Chan --> Handlers
Handlers -- "downcast via as_any()" --> Plugins
World --> Loop
Loop -- "system(&mut World, &mut AssetManager)" --> AM
AM -- "load::<T>(path)" --> Stores[("typed AssetStore<T>")]
AM -- "notify watcher (debug)" --> HR[poll_hot_reload]
HR --> AR
CR -. "inventory::collect!" .- World
AR -. "inventory::collect!" .- AM
SR -. "inventory::collect!" .- EP
- Registration.
App::with_plugin(MyPlugin)pushes the plugin intoPluginStorage, which is aVec<(TypeId, Box<dyn Plugin>)>. Insertion order is preserved on purpose: GL-using plugins mustbuildafterWindowPluginso the GL context exists by the time theirInitsystems run. - Bootstrap.
App::run()callsPluginStorage::bootstrap, which invokesPlugin::build(&self, &mut World, &mut AssetManager)for each plugin in order. Duringbuild, a plugin typically (a) registers ECS systems against one or moreScheduleSlots, (b) adds resources to the world, and (c) registers message-bus handlers for the message types it cares about. The&self(rather thanself) signature is intentional: plugins are stateless once built, and any state a plugin needs to carry into its systems lives as an ECSResourcerather than as plugin fields. - Runtime. The main loop walks the schedule:
PreInit -> Init -> PostInit -> { First, PreUpdate, Update, PostUpdate, PreFixedUpdate, FixedUpdate, PostFixedUpdate, PreRender, Render, PostRender, Last } -> PreShutdown -> Shutdown -> PostShutdown. Every system receives(&mut World, &mut AssetManager). The final system inLastdrains the message bus by callingmessage_bus::process_messages(None).
The bus lives in vtrl_common::message_bus as a single
static MESSAGE_BUS: Lazy<MessageBus>. It wraps an mpmc channel (flume) of
Envelope { message: Box<dyn Message>, timestamp: Instant } plus an
RwLock<Vec<Handler>> and an atomic message-id counter.
The bus is a direct carry-over from Alkahest, where flume was the most
performant mpmc channel available at the time. It predates the plugin
system in this codebase and didn't shape — or get shaped by — the plugin
design; the two are independent abstractions that happen to fit together
cleanly.
pub trait Message: 'static + Send + Sync + Debug {
fn message_type_id(&self) -> TypeId { TypeId::of::<Self>() }
fn message_type_name(&self) -> &'static str { type_name::<Self>() }
fn priority(&self) -> u32 { 0 }
fn requires_ack(&self) -> bool { false }
fn ttl(&self) -> Option<Duration> { None }
fn category(&self) -> Option<&str> { None }
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
}Defaults cover priority, requires_ack, ttl, category, and
message_type_{id,name}. The only methods an implementor must write are
as_any and as_any_mut, which give the bus and handlers a way to upcast a
&dyn Message back to a concrete type via std::any::Any.
priority and requires_ack are aspirational — they're on the trait so
that consumers can already annotate their messages, but the current bus
implementation does not sort by priority or track acknowledgements. They
will be wired up if and when a concrete need shows up; until then they're
free metadata.
The trait-based design was chosen so consumers of the engine can define their
own message types in their own crates and publish them on the same bus the
engine uses, without standing up a parallel bus. Any user type that satisfies
'static + Send + Sync + Debug and implements Message is a first-class
citizen on the bus.
register_handler takes a Box<dyn MessageHandler> and an
Option<TypeId>. A handler registered with None is a generic handler
that sees every message on the bus (used by the debug MessageSink and
useful for tracing). A handler registered with Some(TypeId::of::<T>()) is
typed and only fires when the envelope's message_type_id() matches.
message_bus::register_handler(
Box::new(InputHandler),
Some(TypeId::of::<WindowMessage>()),
)?;Inside the handler, recover the concrete type with as_any().downcast_ref:
impl MessageHandler for InputHandler {
fn call(&self, msg: &dyn Message) {
if let Some(msg) = msg.as_any().downcast_ref::<WindowMessage>() {
match msg {
WindowMessage::Key(code, pressed) => { /* ... */ }
WindowMessage::CursorPosition(x, y) => { /* ... */ }
_ => {}
}
}
}
}- Buffered, not immediate.
sendenqueues anEnvelopeonto the flume channel; handlers only run whenprocess_messagesdrains the queue. The defaultLast-slot system drains the full queue once per frame. - Single end-of-frame drain by design. Everything sent during frame N is processed before frame N+1 begins. Today, messages are exclusively used to nudge other systems to update their internal state — there is no reactivity (no "this handler returns a new event that triggers another handler this frame"). A single drain point keeps that contract clear; if reactivity becomes a real requirement, the per-slot drain alternative will be revisited.
- TTL enforcement. If a message returns
Some(ttl)andInstant::now() - envelope.timestamp > ttl, the message is skipped. Handlers run in registration order;priorityis currently ignored. - Per-message fan-out. For each envelope, the bus iterates the handler
list and dispatches to every generic handler plus every typed handler whose
TypeIdmatches.
A plugin is anything that implements:
pub trait Plugin {
fn build(&self, world: &mut World, asset_manager: &mut AssetManager);
}build is called exactly once, before the schedule starts. Inside build a
plugin can:
- Add resources to the world:
world.add_resource(MyResource::default()). - Register systems for any
ScheduleSlot:world.add_system(slot, |w, mgr| { ... }). - Register message-bus handlers:
message_bus::register_handler(handler, Some(TypeId::of::<MyMsg>())). - Pull initial state from
AssetManagerif needed (e.g. preload a default font).
Plugins do not hold references to each other. They share state through three channels only, and the right channel for a given piece of data is chosen by what kind of data it is:
- ECS resources (
World::add_resource/get_resource[_mut]) for cross-plugin state —CommandBuffer,Viewport,SceneManager,ScriptEngine,AnimationStore,TextureAtlas,FontAtlas. Any plugin can add or consume resources; there's no ownership convention beyond "whichever plugin defines the type usually adds it onInit." - ECS components and queries for per-entity state — data attached
to entities that any plugin can iterate
(
world.view::<(Quad, Transform), ()>()etc.). - The message bus for events — "something happened" signals like
WindowMessage::ResizeorSystemMessage::Shutdownthat any number of systems may want to observe.
The bundled plugins illustrate the pattern:
| Plugin | Owns | Talks to others via |
|---|---|---|
WindowPlugin |
GLFW + GL context, WindowContext, Viewport |
publishes WindowMessage, SystemMessage::Shutdown |
InputPlugin |
GLOBAL_INPUT_STATE (atomic key/mouse table) |
subscribes to WindowMessage |
RenderPlugin |
Renderer, CommandBuffer, atlases |
reads ECS components, drains SceneManager::just_loaded |
SceneManagerPlugin |
SceneManager resource |
loads via AssetManager, exposes just_loaded |
TilemapPlugin |
TileAtlas, TilemapRenderer |
drains just_loaded, pushes commands to CommandBuffer |
EntityScriptingPlugin |
ScriptEngine (Rhai), per-entity Script exec |
uses inventory registries for component/scriptable types |
CollisionPlugin |
broadphase + narrowphase systems | ECS components only |
DebugOverlayPlugin |
overlay text buffer | reads debug print queue, draws via CommandBuffer |
There are two distinct concepts that share the word "resource" in the codebase:
- ECS resources (
vtrl_ecs::resource): typed singletons stored on theWorldand accessed by systems viaget_resource/get_resource_mut. Used for engine state that needs to be visible across systems and plugins (renderer, atlases, viewport, scene manager, script engine, etc.). - Assets (
vtrl_common::asset): on-disk content loaded by theAssetManagerand addressed byAssetHandle(Symbol), whereSymbolis astring-internerhandle for the asset's path.
- Source abstraction.
AssetManagerreads through anAssetSourcetrait. The default impl isDirectorySourcerooted at$VTRL_PROJECT_ROOT/(falling back to./assets). APackSourceis stubbed for release builds but not yet implemented. - Typed stores. Each asset type
T: Assetgets its ownAssetStore<T>, keyed by interned pathSymbol. Stores are kept in aHashMap<TypeId, Box<dyn Any>>and recovered via downcast — so the publicload::<T>/get::<T>API is fully typed even though storage is erased. - Asset trait. An asset is anything implementing
fn load(bytes: Vec<u8>) -> Result<Self>. Built-in assets:Scene,TextureData,Font,AnimationSet,TileSet,EntityScript. - Registration via
inventory. The#[asset]proc-macro emits aninventory::submit! { AssetRegistration::new::<T>(name) }block. At startup,AssetRegistry::buildwalks theinventory::iterto build aHashMap<String, LoaderFn>so the hot-reload watcher can re-invoke the correct loader from a type name string. This means new asset types can be added by downstream crates without editing any central match table. The same pattern is used for#[component]and#[scriptable]: the goal is to keep the consumer-facing API one annotation wide, with the proc-macro handling trait impls,as_any/as_any_mutboilerplate, and engine registration. - Handles are paths. An
AssetHandleis just an interned path symbol. It serializes back to the original path string (so scene files reference assets by path), and deserialization round-trips through the interner.
The SceneManager resource holds three fields: pending (a scene file
queued for loading), current (the loaded scene), and just_loaded — a
Vec<(String, Symbol)> of (asset_type_name, handle) pairs.
When a scene is requested via SceneManager::load_scene(path), the
SceneManagerPlugin's First-slot system:
- Reads the scene file via
AssetManager::read_bytes(bypassing the asset cache — scenes are one-shot). - Parses the RON into
Vec<AssetDef>+Vec<EntityDef>. - For each
AssetDef, callsASSET_REGISTRY.load(asset_type, &mut mgr, path)which dispatches into the correct typedAssetStore. - Pushes each
(asset_type, symbol)ontojust_loaded.
Plugins that need to react to newly loaded assets (e.g. RenderPlugin
uploading a texture into the TextureAtlas, or TilemapPlugin registering
a tileset) drain just_loaded in their PreRender system. Each plugin
retains only the entries it didn't consume, so multiple consumers can
cooperate on the same load list without explicit coordination.
In debug builds, AssetManager::init_hot_reload spins up a notify
recursive watcher on the asset root and stashes the receiver. The
poll_hot_reload method is wired in as a PreUpdate system by App::bootstrap
when debug_assertions are on. On each tick it drains the notify channel,
collects unique modified paths, looks up each path's recorded TypeId, and
re-runs the loader through ASSET_REGISTRY.load. File-in-transition errors
(editors that write-temp-then-rename) are logged at debug and ignored.
The watcher's scope is deliberately limited to the game's asset root. Engine shaders live inside the engine crates and don't change often; scripts are game-level assets and are already under the watched root. If a future workflow needs shader hot-reload during engine development, it will be added as a separate, opt-in watcher.
- Path-as-key. Cheap, human-readable handles that survive serialization for free. Cost: renaming a file invalidates every handle that referenced the old path. There's no content-hashed cache.
- Type-erased stores via
TypeId. Lets new asset types plug in with zero central wiring at the cost of adowncaston everyget. - Synchronous loading. Loading is blocking on the main thread. Fine for small 2D content; not yet suitable for streaming large worlds.
- No reference counting. Once loaded, an asset lives for the lifetime
of the
AssetManager. There is no unload/eviction; this keeps the ownership model simple but means long-running games leak. - Hot reload is best-effort. It re-runs the loader and replaces the
cached entry, but consumers that have already copied data out (e.g. the
TextureAtlasafter upload) won't see the new version until they re-drain. just_loadedis cooperative. Consumersretainonly the entries they don't claim, and put the rest back. So far no consumer has silently failed to drain its entries; if that becomes a real failure mode, an explicit subscription model is the natural next step.
Considered alternatives:
- A monolithic engine struct with hard-coded subsystem fields. Simpler, but every new subsystem (debug overlay, tilemaps, scripting) requires touching the central struct, and there's no clean way for a downstream game to swap or omit one.
- Bevy-style typed plugin DAG with explicit dependencies. More
expressive, especially around ordering and conditional builds, but it
pushes a lot of complexity (system sets, conditions,
App::add_pluginstrait magic) onto users who mostly just want to register some systems.
The chosen design — a plain Vec<(TypeId, Box<dyn Plugin>)> with one
build(&mut World, &mut AssetManager) call — gets ~90% of the value of the
DAG approach with a fraction of the surface area. Insertion order is the
ordering contract, and plugins are responsible for putting their work in
the right ScheduleSlot. The comment on PluginStorage::storage documents
the exact reason a Vec was chosen over a HashMap: a hash map randomized
GL-context initialization order across runs.
Formalizing this as a dependencies() method on Plugin is on the
roadmap — the current implicit ordering is fine while the plugin set is
small and stable, and the migration becomes mandatory the moment a frame
scheduler needs the dependency graph (see "What I'd redesign" below).
Considered alternatives:
- Direct method calls between subsystems. Fastest, but creates a tangle
of crate-level dependencies (e.g.
vtrl_renderneeding to know about every other plugin that wants to schedule a redraw). It also forecloses on user-extensibility: a game crate can't easily inject itself between GLFW events and the input plugin. - Per-subsystem channels. Each plugin exposes a typed sender/receiver pair. Cleaner static dispatch, but every new producer-consumer pair is a new wiring step at app build time, and there's no place for cross-cutting observers (e.g. logging every event for replay).
- A typed broadcast bus per message type. Type-safe by construction, but the number of buses grows with the number of message types, and there's no single drain point.
A single global mpmc bus with a trait-object envelope was chosen because:
- It is publish-anywhere, subscribe-anywhere without per-pair plumbing.
- It is extensible from user crates without any registration step
beyond
impl Message for MyType. - It provides a single drain point (
process_messagesinLast) which makes ordering predictable: every message sent during frame N is processed by the end of frame N.
The cost is paid in three places:
- Implicit ordering. Because dispatch happens at end-of-frame, a
message sent in
Updateis not visible to its handlers until then. A handler that ends up reading a state mutated by another handler in the same drain has an order dependency that's invisible from either site. - Debugging. Following a message from emitter to consumer requires
grepping for
TypeId::of::<...>()ordowncast_ref::<...>()rather than following a function call. The debug-onlyMessageSinklogs every message to mitigate this. - Boxing on send. Every message is boxed into the envelope. For the current event volume (window events, shutdown, occasional system signals) this is irrelevant; it would matter if the bus were used for per-frame per-entity events.
Covered above; the headline tradeoff is that the pipeline optimizes for
zero central wiring (via inventory) and scene-driven discovery
(via just_loaded) at the cost of a less rigid model: there's no
compile-time guarantee that a scene's AssetDef lists an asset_type
that any registered loader understands, and no compile-time guarantee that
every consumer that should drain just_loaded does so.
- The renderer is instance-batched:
Quad/Spriteentities are collected intoVec<QuadInstance>and submitted as a singleDrawQuadscommand per pass. Tilemap layers are submitted as bulkTileInstancearrays. - A
CommandBufferresource decouples what to draw from when to issue GL calls — systems push commands duringRender, and a singlePostRendersystem replays them in order. This keeps GL state changes centralized and makes it easy for non-rendering plugins (debug overlay, collider visualization) to inject draws without touching the renderer. - The bus's flume channel is unbounded by default. In practice, observed
queue depth is ~50–100 events per frame, dominated by GLFW input
events. There's no real risk of unbounded growth at current usage; if
the bus starts carrying per-entity or per-frame events, switching to
boundedand measuring backpressure becomes worth doing.
Profiling so far has been ad-hoc rather than systematic: the demo's debug
overlay shows live FPS, and at ~100k randomly-updated quads per frame the
engine sustains ~150 FPS on the development machine. A proper pass with
per-ScheduleSlot frame-time instrumentation, GPU timer queries on the
renderer, and bus allocation tracking is on the roadmap and not yet done.
Vitriol is itself the redesign — it's the second iteration after Alkahest,
written from scratch with the lessons of that work in mind. The biggest
correction was decoupling the renderer from the OpenGL crate: in Alkahest
the two were tightly fused, which meant any new renderer (e.g. tilemaps)
either had to live inside the OpenGL crate or duplicate it. Vitriol
restructures the renderer as its own plugin that other plugins can build
their own renderers alongside, with shared primitives in vtrl_opengl.
What's still on the list, even given a clean slate:
- A declarative plugin dependency graph. Today,
WindowPluginmust be inserted before GL-using plugins, enforced only by a comment. A realPlugin::dependencies()would catch this at app build time and is a prerequisite for concurrency (below). - Concurrency, which has been actively deferred. Landing it means revisiting the singletons (input state, the global message bus), adding access-control declarations to every plugin/system so a frame scheduler can build the frame graph without deadlocks or aliasing violations, and finally formalizing plugin dependencies. None of these are interesting in isolation; together they're a serious restructure.
- Async asset loading with a typed handle that resolves to
Pendinguntil ready. Synchronous loading is fine for the demo, but won't scale to streaming worlds. - Content-hashed asset keys instead of path symbols, so renames don't invalidate handles and so hot-reload can be guaranteed idempotent. Path-rename invalidation hasn't bitten yet, but it's a known sharp edge.
Prerequisites: Rust 1.85+ (edition 2024), a working OpenGL 4.5 driver, and
the system dependencies for glfw and freetype (on Debian/Ubuntu:
apt install cmake libglfw3-dev).
git clone <repo-url> vitriol
cd vitriol
cp .env.sample .env # sets VTRL_PROJECT_ROOT
cargo run --releaseAll demo assets are committed to the repo, so the clone-and-run path
above produces a visible window with no extra setup. The binary crate at
the workspace root (vitriol) boots the engine with
with_default_plugins(), loads scenes/demo.vtrl, and registers a couple
of Rhai-callable helpers (animation_name, direction_from_velocity).
The demo scene, animations, and scripts live under src/scenes,
src/animations, and src/scripts.
To build a game on top of the engine, depend on vtrl_core and use the
prelude:
use vtrl_core::prelude::*;
fn main() -> Result<()> {
App::new()
.with_default_plugins()
.with_plugin(MyGamePlugin)
.with_system(ScheduleSlot::Update, |world, assets| {
// ...
})
.run()
}In rough priority order:
- Dear ImGui integration — the foundation for everything editor-shaped.
- In-engine tilemap editor, built on the ImGui layer. Hand-authoring tilemaps in RON is painful; this is the most obvious quality-of-life win for the game project.
- A full scene editor, growing out of the tilemap editor once the ImGui plumbing and asset-binding patterns are settled.
- Asset packs (
PackSource) for release builds — custom binary format with an index/table at the head of the file and per-asset(size, offset)entries. The trait already exists; the format and implementation do not. - Backend-agnostic renderer — peel the GL specifics out of
vtrl_renderso a Vulkan or wgpu backend can slot in behind the sameRenderCommandinterface. - Concurrency — see the redesign section above. This unlocks real parallel work but requires the plugin-dependency and access-control refactors first.
- Async asset loading with
Pending-resolving handles. - Content-hashed asset keys to survive renames and make hot-reload idempotent.
- Audio plugin.
- Bus subscriptions per
ScheduleSlotrather than a single end-of-frame drain — deferred until a real reactivity requirement forces the issue. - A real profiling pass — frame-time budget instrumentation per
ScheduleSlot, GPU timer queries on the renderer, allocation tracking on the bus.