libDQG
libDQG is a personal 2D/3D game framework in Rust, built on wgpu and
winit. It’s a hobby successor to an earlier DirectX 9
framework, moving away from OOP/inheritance toward data-driven design. It isn’t meant to be a
general-purpose production engine — it favors simplicity and directness over extensibility.
The workspace has three crates:
core— thelibdqglibrary: the framework itself (renderer, scene/ECS, input, scripting runtime, and so on).demo— a small game exercising the framework, effectively the framework’s own integration test/sample.editor— a visual scene editor built onlibdqg, with its own project file format, mesh-accurate picking, and a Rhai-based scripting system for entity behavior.
Where to go from here
- Architecture — how the pieces fit together: the game loop, the renderer, the ECS, transforms, and the editor.
- Scripting — the API available to
.rhaiscripts attached to entities in the editor. - API reference — generated documentation for every public type and
function in the
libdqgandeditorcrates.
Source
The source code lives at github.com/doqin/libDQG.
Architecture
This page describes how libDQG’s pieces fit together. For the full API surface, see the API reference; this page is the map, not the territory.
Workspace layout
The workspace root’s Cargo.toml defines three crates:
core/— thelibdqglibrary crate (the framework itself).demo/— ademobinary crate exercising the framework; more an integration test and sample game than a real product.editor/— aneditorbinary crate: a visual scene editor built onlibdqg(anegui-based UI, its own project file format, mesh-accurate picking, and a.rhaiscripting system).
libdqg re-exports glam (pub use glam;) so downstream crates don’t
need to pin their own version to stay compatible with types like Sprite::transform.
Game loop: App → SceneManager → Scene
game.rs—GameBuilderconfigures window title/size/titlebar/clear color and builds aGame, which owns awinit::EventLoopand runs it againstApp.app.rs—Appimplementswinit::ApplicationHandler. It owns theWindow, theRenderer,InputState, and theSceneManager, and drives the frame loop:RedrawRequested→SceneManager::update→Renderer::render(clear_color, |pass| { scene.render(pass) }). It also handles custom window-chrome logic (drag-resize borders, integrated titlebar hit-testing) whenintegrated_titlebaris enabled, since window decorations are turned off in that mode.scene.rs— user code implements theScenetrait (update+render);updatereturns aSceneTransition(Push/Pop/Replace/Next/Previous/Quit/None) thatSceneManageracts on. Scenes are a flatVecwith a current index, not a stack machine with separate push/pop semantics for render vs. update.- Resource loading (textures, models) happens lazily inside
Scene::update, gated onrenderer: Option<&mut Renderer>beingSome. The renderer is only available once the window/GPU surface exists (afterresumed), so scenes must tolerateNoneon early frames.
Renderer: a thin wgpu wrapper + higher-level draw helpers
renderer/mod.rs—Rendererowns the wgpu device/queue/surface, the camera uniform buffer, the depth buffer, and every built-in pipeline (immediate shapes, screen-space sprites, world-space sprites, models).Renderer::renderbegins the wgpu render pass (color + depth attachments) and hands aDrawPassinto a caller-supplied closure — this is the only place a frame is actually submitted.Rendereralso exposes a generic pipeline-building API (create_shader_module,create_render_pipeline,create_buffer,create_buffer_init) built on wrapper types inrenderer/types.rs(VertexFormat,PrimitiveState,DepthStencilState, etc.). These wrapper enums exist so downstream crates (likedemo) can describe custom pipelines without depending onwgpudirectly — every wrapper type has ato_wgpu()/from_wgpu()conversion. Any custom pipeline built this way that participates in the main pass must declare a depth-stencil state usingRenderer::DEPTH_FORMAT(Depth32Float) or it falls back to a no-op overlay depth state.renderer/draw_pass.rs—DrawPasswrapswgpu::RenderPasswith the same wrapper-type philosophy. Higher-level draw calls (draw_rect/draw_ellipse/draw_line/draw_ui_sprite/draw_world_sprite/draw_model) live inextras.rs(immediate shapes + sprites) andmodel.rs(OBJ models).- Two coordinate spaces coexist per frame: screen space (pixel coordinates, e.g.
draw_rect,Sprite::draw, the titlebar) uses depth-untested overlay rendering so draw order wins; world space (draw_world_sprite,draw_model, camera-relative) is depth-tested against the sharedDepth32Floatbuffer. - Shaders are plain
.wgslfiles undercore/shaders/, pulled in viainclude_str!.
Camera
Camera (camera.rs) is an FPS-style camera: position plus yaw/pitch (up is fixed to world
+Y, so there’s no roll), rather than an eye/target look-at pair. Camera::forward() derives the
look direction from yaw/pitch, and Camera::look_at(target) is a convenience for call sites that
think in terms of a point to look at rather than angles. Renderer owns exactly one Camera;
Renderer::render re-derives the GPU view-projection uniform from it every frame, so swapping
*renderer.camera_mut() = ... before calling render is the whole mechanism for changing what’s
displayed — there’s no separate multi-camera concept in the renderer itself. (The editor builds a
camera-entity concept in the ECS on top of this — see The editor below.)
Transforms
transform.rs defines Transformable, a default-method trait giving any type with a glam::Mat4
(Sprite, Model) a fluent transform API (translate/rotate/scale/flip_x/…). Rotations
and scales compose about Transformable::pivot() (defaults to local origin; Sprite overrides it
to its quad center so sprites spin in place). All operations post-multiply onto the existing
matrix in local space — chained calls read outside-in (e.g. rotate(...).translate(...)
translates along the rotated axis). set_transform/reset_transform bypass composition
entirely when you want to drive the matrix directly.
ECS: World, Entity, ComponentStore
core/src/ecs/ is a small hand-rolled ECS, not a library like hecs/bevy_ecs. Entity (index +
generation) is allocated/recycled by EntityAllocator; ComponentStore<T> is a sparse
Vec<Option<(generation, T)>> keyed by entity index, generation-checked on every access so a
stale Entity never reads/writes a recycled slot. There’s no query/archetype abstraction —
components are just named ComponentStore<T> fields on World.
world.rs’s World holds transforms, names, renderables, scripts, and cameras, each its
own ComponentStore. World::sync_transforms() copies each entity’s Transform into its
Renderable’s GPU-facing matrix once per frame. A CameraComponent (fov/near/far/active)
attached to an entity, combined with its Transform, derives a renderable Camera via
CameraComponent::derive_camera — this is what lets the editor place a camera in a scene (see
below) rather than only having the one built into Renderer.
The editor
The editor (editor/) is a scene editor built on libdqg. editor/src/main.rs is the entry
point; it builds a Game starting at a menu scene (New/Open/Recent project), which hands off to
EditorScene once a project is chosen. The UI is all egui, drawn across menu bar, hierarchy
panel, assets panel, and inspector.
- Project format — a
Projectis a folder on disk (a manifest,assets/{textures,models}/,scenes/main.ron,scripts/). Entities serialize as anEntityRecord(name/transform/renderable/camera/scripts); assets and scripts are referenced by project-root-relative path, never by an ID/handle. - Picking — mesh-accurate ray/triangle picking for models, ray/sphere for sprites and camera entities, driving hover/selection highlight in the viewport.
- Camera entities — an entity with a
CameraComponentrenders as a frustum gizmo in Edit mode and can be marked “active”; entering Play mode swaps the renderer’s camera to whichever entity is active, in place of the editor’s own fly camera. - Scripting — entities carry a stack of
.rhaiscript attachments (ScriptList/ScriptAttachmentincore/src/scripting.rs, aWorldcomponent like any other), created from the Assets panel and attached/reordered/removed from the Inspector. See Scripting for the full API scripts can use.ScriptRuntimecompiles/caches one RhaiASTper script path and runs each attachment’son_start()/on_update(dt, input)against a minimalentity/world/inputAPI — deliberately small; a script mostly only touches its own entity, plus limited cross-entity access viaworld.find(name).EditorScene’s Play/Stop toggle snapshots the wholeWorldbefore running scripts and restores it on Stop, dropping theScriptRuntime(and all script state) with it. A script that errors repeatedly auto-disables itself, and errors surface in a small overlay rather than crashing the editor.
Other core modules
camera.rs—Camera, described above.input.rs—InputState/MouseStatetrack per-frame key/button press/hold state and mouse position/wheel motion from winit events;Appclears frame-transient state after eachRedrawRequested.titlebar.rs/platform.rs— custom-drawn titlebar and Windows-specific chrome (rounded corners viawindows-sys) used whenintegrated_titlebar(true).util.rs—resolve_resource_path(exe-relative asset lookup fallback) andslice_to_bytes(raw byte view of a&[T]for uploading to GPU buffers).
Scripting
Entities in the editor can have behavior attached to them by writing small scripts in
Rhai, a lightweight scripting language embedded directly in the editor. This
page documents the API available to those scripts. It doesn’t cover the Rhai language itself
(variables, if/else, loops, arrays, …) — for that, see the
Rhai Language Reference. Everything on this page is specific to
this editor.
Getting started
- Open the Assets panel at the bottom of the editor and find the Scripts section.
- Click New Script to create a script file (
Script.rhai,Script2.rhai, …) pre-filled with a starting template. - Select an entity, open the Inspector panel on the right, and under Scripts click Attach Script, then pick your new script.
- Click Play in the top menu bar. Your script now runs every frame until you click Stop.
You can attach more than one script to the same entity — they run top to bottom in the order shown in the Inspector, which you can change with the Up/Down buttons there. Each has its own checkbox to enable/disable it without removing it.
To edit a script’s contents, click its tile in the Assets panel. The first time you do this, you’ll be asked to pick a text editor (any program that can open a plain text file); it’s remembered after that, so later clicks just open straight away. Right-click a script tile for Rename or to change your remembered editor.
Script structure
A script is a plain text file defining up to two functions:
let on_start = || {
// Runs once, right when Play starts.
};
let on_update = |dt, input| {
// Runs every frame while playing.
};
Both are optional — a script with neither doesn’t do anything (harmless), and a script with only one of them just skips the other.
Important: they must be written exactly like this — let name = |args| { ... }; — not as
fn on_update(dt, input) { ... }. This isn’t a style preference: only a closure assigned to a
let variable can keep its own private state between frames (see
Keeping state between frames below). A plain fn can’t see
outside variables at all in Rhai, so it wouldn’t be able to remember anything from one frame to
the next.
on_update’s parameters:
dt— the time in seconds since the last frame (a small number, typically around0.016at 60 FPS). Multiply speeds bydtso movement is frame-rate independent.input— the current keyboard and mouse state. See Theinputobject.
The entity object
Every script has automatic access to a variable called entity — the entity the script is
attached to. This is the same kind of value world.find(...) hands back for other entities
(see The world object below) — there’s only one entity type with one set
of members, whether it’s your own entity or one you looked up.
| Member | Description |
|---|---|
entity.x, entity.y, entity.z | Get or set the entity’s position directly, one axis at a time. |
entity.translate(x, y, z) | Moves the entity by this amount (adds to its current position). |
entity.rotate(x, y, z) | Rotates the entity by this amount, in radians, around each axis. This is a delta — it turns the entity further from wherever it currently is, it doesn’t set an absolute angle. |
entity.look_at(x, y, z) | Points the entity at the given world position, down its local -Z axis. Unlike rotate, this sets the rotation outright rather than adding to it — call it every frame to keep facing a moving point (like a player). This is the axis a camera entity’s CameraComponent looks down too, so it doubles as “point this camera at (x, y, z)”. |
entity.scale(x, y, z) | Multiplies the entity’s current scale by this amount. entity.scale(2.0, 2.0, 2.0) doubles its size; entity.scale(1.0, 1.0, 1.0) leaves it unchanged. |
entity.name() | Returns the entity’s name (the one shown in the Hierarchy panel), as text. |
entity.set_name(name) | Renames the entity. |
entity.despawn() | Removes the entity from the scene. If a script despawns its own entity, none of that entity’s other scripts run for the rest of that frame. |
entity.set_persistent(persistent) | Marks (or unmarks) the entity as surviving a scene change (see The scene object) instead of being removed along with everything else in the outgoing scene. Its scripts keep running uninterrupted across the change, with all their state intact. |
entity.attach_script(path) | Attaches another script to this entity, by its project-relative path (the same form shown in the Inspector, e.g. "scripts/Move.rhai") — and, unlike attaching one from the Inspector’s Assets panel, it starts running immediately, the same session, not just after the next Stop/Play. |
entity.set_script_enabled(index, enabled) | Enables or disables one of the entity’s attached scripts by its position in the Inspector’s Scripts list (0 is the first one). |
entity.set_sprite(path) | Attaches (or swaps to) a sprite, loading the texture at path (project-relative, e.g. "assets/textures/player.png") — same idea as the Inspector’s Attach Sprite picker. Replaces whatever renderable the entity already had, if any. |
entity.set_model(path) | The model counterpart to set_sprite — loads an .obj at path (e.g. "assets/models/crate.obj"). |
entity.detach_renderable() | Removes whatever sprite/model the entity currently has, if any. Does nothing (not an error) if it didn’t have one. |
let on_update = |dt, input| {
entity.translate(0.0, 0.0, -1.0 * dt); // moves 1 unit/second along -Z
entity.rotate(0.0, 1.5 * dt, 0.0); // spins around Y at 1.5 radians/second
};
A note on rotation: rotate composes with whatever rotation the entity already has, the same way
turning a steering wheel further turns the car further, rather than snapping it to face a
specific direction. If you want to face a specific point outright — a moving target, most often —
use look_at instead; see the table above.
A note on set_sprite/set_model: unlike the other entity methods, these read a file from disk
and upload it to the GPU — the same cost as clicking Attach Sprite/Attach Model in the
Inspector, just triggered from a script instead of a click. Call them when something actually
changes (an on_start, or in response to an event), not unconditionally every frame from
on_update — that would reload and re-upload the same asset 60 times a second for no reason.
let on_start = || {
entity.set_sprite("assets/textures/idle.png");
};
The world object
Every script also has automatic access to a variable called world, for reaching entities other
than your own:
| Member | Description |
|---|---|
world.find(name) | Looks up an entity by its name (the one shown in the Hierarchy panel). Returns an entity value — the same kind entity is — if one is found, or () (Rhai’s “nothing” value) if not. If more than one entity shares that name, you get whichever was created first. |
world.spawn_entity(name, x, y, z) | Creates a new entity at the given position and returns it, ready to use right away (world.spawn_entity("Bullet", entity.x, entity.y, entity.z).translate(0.0, 0.0, -1.0) works in the same line). |
let on_update = |dt, input| {
let target = world.find("Player");
if target != () {
entity.x = target.x; // follow the entity named "Player" on the x axis
}
};
A couple of things worth knowing about world.find(...):
- It reflects that entity’s position/name as of the start of the current frame — if another
script already moved or renamed that entity earlier this same frame,
findwon’t see that change until next frame. Your ownentity, by contrast, is always fully up to date, including changes your own script just made a moment earlier in the same call. This only matters if you’re chaining cross-entity logic within a single frame; for most scripts (following another entity, checking its position, etc.) it’s not something you’ll notice. - Anything you do to an entity reached via
find(.translate(...),.despawn(), …) takes effect right away, same as it does forentity— the frame-start staleness only applies to whatfindhands you, not to writes made through it.
The scene object
A project can hold more than one scene — see the Scenes section of the Assets panel. scene
gives a script a way to switch which one is currently loaded:
| Member | Description |
|---|---|
scene.change(path) | Loads a different scene, by its project-relative path (e.g. "scenes/level2.ron", the same form shown in the Assets panel). |
let on_update = |dt, input| {
if input.is_pressed("Enter") {
scene.change("scenes/level2.ron");
}
};
A scene change removes every entity in the current scene — except ones marked persistent with
entity.set_persistent(true) — and spawns the new scene’s entities in their place, starting their
scripts. This includes the entity whose script called scene.change(...): unless it’s marked
persistent, it’s removed along with the rest of the outgoing scene.
// A "game manager" entity that should survive every scene change in the game.
let on_start = || {
entity.set_persistent(true);
};
Marking an entity persistent is a script-only, runtime choice — there’s no checkbox for it in the
editor, the same way there’s nothing to configure ahead of time about it in the Inspector. It only
takes effect once on_start/on_update actually calls entity.set_persistent(true).
The input object
input (on_update’s second parameter) tells you what’s happening with the keyboard and mouse
this frame:
| Method | True when… |
|---|---|
input.is_held(name) | the key is currently held down (true for every frame it’s held, including the first). |
input.is_pressed(name) | the key was just pressed down this frame (true for exactly one frame per press, even if held afterward). |
name is a piece of text identifying a physical key. The common ones:
| Category | Names |
|---|---|
| Letters | "KeyA" through "KeyZ" |
| Digits | "Digit0" through "Digit9" |
| Arrows | "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" |
| Modifiers | "ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight" |
| Whitespace/editing | "Space", "Enter", "Tab", "Backspace", "Escape" |
| Function keys | "F1" through "F12" (and beyond, rarely needed) |
Any physical key on the keyboard works, not just the ones above — the name always matches the
key’s own physical position (e.g. "BracketLeft" for [), not what’s printed on it, so it’s
consistent across keyboard layouts. This mostly (not always — the Windows/Cmd key is
"SuperLeft"/"SuperRight" here rather than the web’s "MetaLeft"/"MetaRight") resembles a web
browser’s KeyboardEvent.code naming, if you want a mental model — for the exact, definitive list
of every supported name, see the KeyCode enum in
core/src/types.rs. A
misspelled or unrecognized name is simply never held/pressed, it won’t cause an error.
let on_update = |dt, input| {
if input.is_held("KeyW") {
entity.translate(0.0, 0.0, -5.0 * dt);
}
if input.is_pressed("Space") {
entity.translate(0.0, 1.0, 0.0); // hop, once per press
}
};
Mouse
| Method | Description |
|---|---|
input.is_mouse_held(name) | true while the given mouse button is held down. |
input.is_mouse_pressed(name) | true for exactly one frame, the moment the button goes down. |
input.mouse_x(), input.mouse_y() | the cursor’s current position, in window pixels (top-left origin). |
input.mouse_dx(), input.mouse_dy() | how far the cursor moved since last frame — the usual building block for mouse-look. |
input.mouse_wheel() | scroll wheel movement this frame (positive is up/away from you). Zero most frames. |
name for the mouse buttons is one of "Left", "Right", "Middle", "Back", "Forward" — same
quiet-on-typo behavior as keyboard names.
let on_update = |dt, input| {
// Mouse-look while the right button is held, like an orbit/fly camera.
if input.is_mouse_held("Right") {
entity.rotate(0.0, input.mouse_dx() * 0.002, 0.0);
}
};
Keeping state between frames
Since scripts are let-bound closures, any variable declared alongside them in the same script
is automatically remembered from one call to the next — this is how you build up state like a
timer, a counter, or a toggle:
let elapsed = 0.0;
let on_update = |dt, input| {
elapsed += dt;
entity.y = 0.5 + 0.25 * sin(elapsed * 3.0); // gentle bob up and down over time
};
elapsed isn’t reset every frame — it keeps accumulating for as long as Play is running. It does
reset when you click Stop (and Play again), since every script’s state starts fresh each time
Play begins.
Play and Stop
Scripts only run while the editor is in Play mode. When you click Play:
- If any open scene tab has unsaved changes (shown as a
*on its tab), you’re asked whether to save everything first, play anyway, or cancel. This matters becausescene.change(...)(see Thesceneobject) loads a.ronfile straight off disk — if you’re editing a scene in one tab and a script switches to it from another during this Play session, playing without saving means it loads whatever was last saved there, not what’s shown in its tab right now. - Every enabled script attached to every entity has its
on_startcalled once (if it has one). - Every entity’s Transform panel in the Inspector becomes read-only for the duration — a script moving the entity every frame would otherwise fight with you dragging the same fields.
- Saving is disabled for the duration too — see why under Stop, below.
When you click Stop, the whole scene snaps back to exactly how it was the moment you clicked Play — not just position/rotation/scale, but names, which scripts are attached to what, what entities look like, and which entities exist at all:
- Every entity’s position, rotation, and scale snap back to what they were before Play.
- Any entity a script renamed goes back to its old name; any script it attached (via
entity.attach_script(...)) or enabled/disabled (viaentity.set_script_enabled(...)) reverts too. - Any entity whose sprite/model a script changed (
set_sprite/set_model/detach_renderable) goes back to whatever it was showing before Play. - Any entity a script spawned (
world.spawn_entity(...)) disappears; any entity a script despawned (entity.despawn()) comes back. - Any
scene.change(...)that happened during Play is undone — you’re back in the scene you were editing when you clicked Play, exactly as it was, even after several scene changes. - All script state (including things like
elapsedabove) is discarded.
Play-testing never permanently changes your scene — that’s also why Save is disabled while Playing: without that, saving mid-Play would bake all of the above into the scene file instead of letting Stop discard it.
Errors
If a script fails to compile, or throws an error while running (e.g. calling
entity.rotate("a", "b", "c") with text instead of numbers), you’ll see a message in the
bottom-left corner of the screen rather than the editor crashing. A script that keeps failing
every frame for five frames in a row disables itself automatically (unchecking its box in the
Inspector’s Scripts list) so it doesn’t spam the screen with errors forever. It stays disabled —
including across Stop and Play again — until you manually re-check its box.
A complete example
A script that spins an entity continuously and lets WASD move it around:
let spin_speed = 2.0;
let movement_speed = 5.0;
let on_update = |dt, input| {
entity.rotate(0.0, spin_speed * dt, 0.0);
if input.is_held("KeyW") {
entity.translate(0.0, 0.0, -movement_speed * dt);
}
if input.is_held("KeyS") {
entity.translate(0.0, 0.0, movement_speed * dt);
}
if input.is_held("KeyD") {
entity.translate(movement_speed * dt, 0.0, 0.0);
}
if input.is_held("KeyA") {
entity.translate(-movement_speed * dt, 0.0, 0.0);
}
};
Current limitations
This is an early version of scripting, kept intentionally small. Things scripts can’t do yet:
- Truly remove/detach a script from an entity (attaching and enabling/disabling are supported;
removal isn’t yet — this is specifically about the Scripts list, not about
detach_renderable, which is fully supported). - Read or affect anything outside of Play mode (scripts don’t run in Edit mode at all).
There’s also one rough edge worth knowing about rather than being surprised by: attaching a
script from the Inspector’s Assets panel, or re-enabling a disabled one there by hand, while
already in Play mode doesn’t make it start running — only scripts that were enabled and attached
at the moment you clicked Play, plus anything attached since via entity.attach_script(...),
actually run that session. Click Stop and Play again to pick up a change made through the
Inspector.
If you need one of these, it’s worth raising — the API is deliberately minimal for now, not permanently limited.