Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 — the libdqg library: 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 on libdqg, 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 .rhai scripts attached to entities in the editor.
  • API reference — generated documentation for every public type and function in the libdqg and editor crates.

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/ — the libdqg library crate (the framework itself).
  • demo/ — a demo binary crate exercising the framework; more an integration test and sample game than a real product.
  • editor/ — an editor binary crate: a visual scene editor built on libdqg (an egui-based UI, its own project file format, mesh-accurate picking, and a .rhai scripting 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 — GameBuilder configures window title/size/titlebar/clear color and builds a Game, which owns a winit::EventLoop and runs it against App.
  • app.rs — App implements winit::ApplicationHandler. It owns the Window, the Renderer, InputState, and the SceneManager, 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) when integrated_titlebar is enabled, since window decorations are turned off in that mode.
  • scene.rs — user code implements the Scene trait (update + render); update returns a SceneTransition (Push/Pop/Replace/Next/Previous/Quit/None) that SceneManager acts on. Scenes are a flat Vec with 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 on renderer: Option<&mut Renderer> being Some. The renderer is only available once the window/GPU surface exists (after resumed), so scenes must tolerate None on early frames.

Renderer: a thin wgpu wrapper + higher-level draw helpers

  • renderer/mod.rs — Renderer owns 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::render begins the wgpu render pass (color + depth attachments) and hands a DrawPass into a caller-supplied closure — this is the only place a frame is actually submitted.
  • Renderer also exposes a generic pipeline-building API (create_shader_module, create_render_pipeline, create_buffer, create_buffer_init) built on wrapper types in renderer/types.rs (VertexFormat, PrimitiveState, DepthStencilState, etc.). These wrapper enums exist so downstream crates (like demo) can describe custom pipelines without depending on wgpu directly — every wrapper type has a to_wgpu()/from_wgpu() conversion. Any custom pipeline built this way that participates in the main pass must declare a depth-stencil state using Renderer::DEPTH_FORMAT (Depth32Float) or it falls back to a no-op overlay depth state.
  • renderer/draw_pass.rs — DrawPass wraps wgpu::RenderPass with the same wrapper-type philosophy. Higher-level draw calls (draw_rect/draw_ellipse/draw_line/draw_ui_sprite/ draw_world_sprite/draw_model) live in extras.rs (immediate shapes + sprites) and model.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 shared Depth32Float buffer.
  • Shaders are plain .wgsl files under core/shaders/, pulled in via include_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 Project is a folder on disk (a manifest, assets/{textures,models}/, scenes/main.ron, scripts/). Entities serialize as an EntityRecord (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 CameraComponent renders 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 .rhai script attachments (ScriptList/ScriptAttachment in core/src/scripting.rs, a World component like any other), created from the Assets panel and attached/reordered/removed from the Inspector. See Scripting for the full API scripts can use. ScriptRuntime compiles/caches one Rhai AST per script path and runs each attachment’s on_start()/on_update(dt, input) against a minimal entity/world/input API — deliberately small; a script mostly only touches its own entity, plus limited cross-entity access via world.find(name). EditorScene’s Play/Stop toggle snapshots the whole World before running scripts and restores it on Stop, dropping the ScriptRuntime (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/MouseState track per-frame key/button press/hold state and mouse position/wheel motion from winit events; App clears frame-transient state after each RedrawRequested.
  • titlebar.rs / platform.rs — custom-drawn titlebar and Windows-specific chrome (rounded corners via windows-sys) used when integrated_titlebar(true).
  • util.rs — resolve_resource_path (exe-relative asset lookup fallback) and slice_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

  1. Open the Assets panel at the bottom of the editor and find the Scripts section.
  2. Click New Script to create a script file (Script.rhai, Script2.rhai, …) pre-filled with a starting template.
  3. Select an entity, open the Inspector panel on the right, and under Scripts click Attach Script, then pick your new script.
  4. 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 around 0.016 at 60 FPS). Multiply speeds by dt so movement is frame-rate independent.
  • input — the current keyboard and mouse state. See The input object.

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.

MemberDescription
entity.x, entity.y, entity.zGet 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:

MemberDescription
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, find won’t see that change until next frame. Your own entity, 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 for entity — the frame-start staleness only applies to what find hands 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:

MemberDescription
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:

MethodTrue 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:

CategoryNames
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

MethodDescription
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 because scene.change(...) (see The scene object) loads a .ron file 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_start called 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 (via entity.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 elapsed above) 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.