Skip to content

Latest commit

 

History

History
3239 lines (2943 loc) · 208 KB

AGENTS.md

File metadata and controls

3239 lines (2943 loc) · 208 KB

AGENTS.md

This file provides guidance to coding agents (Codex, opencode and others) when working with code in this repository.

Project

Blockloom is a Qt/QML desktop app (Windows/Linux/macOS) for building games out of blocks - a Scratch-style block editor driving a real game world, in 2D or in 3D. A project is a world plus a set of actors, each with its own block canvas; pressing Play runs those blocks against a Bevy world, shown in the editor's Game view.

Common commands

Patched Bevy/wgpu sources are generated into gitignored .patched-deps/ from the small diffs under patches/. just Cargo recipes prepare them automatically. Before invoking Cargo directly on a fresh checkout, run just prepare-patched-deps. See patches/README.md for prerequisites and updating patches.

just build              # cargo build --release --workspace (the normal build)
just run                # build, then launch target/release/blockloom
just qml-preview        # Qt 6.12 live QML edits, with the real backend
just replace-fast       # Linux: rebuild/reinstall editor and runtime, reuse staged players
just replace [jobs]     # build editor/web concurrently, reuse runtime, reinstall
just prune-target --dry-run          # inspect the 20 GiB build-cache budget
cargo build --workspace && target/debug/blockloom   # debug build/run - faster iteration
just test               # cargo test --workspace (blockloom-core has the bulk of them)
cargo bench -p blockloom-core --bench vm   # block VM ns/tick over a few canvases
just player             # stage the hard-optimized player a built game ships
just web-check          # runtime check-build for wasm32-unknown-unknown (Phase 8)
just web-player [profile]            # stage the WebGPU player the Web build target ships
just web-player release              # faster web development build without dist's fat LTO
just web-build <project> [out]       # a project as one self-contained .html
just web-smoke <page.html> [--scripts N] [--moves ACTOR]  # headless run of a built page

just replace divides the CPU count across its two builds by default; just replace 4 uses four Cargo jobs per build. CARGO_BUILD_JOBS overrides the default per-build limit. Web players use target/web-build for both final and intermediate artifacts to avoid the native player's host-tool output locks; their first build with this cache recompiles dependencies. Staged player paths stay the same. The native player reuses the optimized runtime from the workspace build, avoiding a second native compilation. BLOCKLOOM_NATIVE_PROFILE=dist just replace builds the fat-LTO native player alongside the editor and web player, dividing CPUs across three builds. just player still uses dist. Replacement fetches native and web dependencies once before starting the builds offline, so network access does not serialize the parallel compilation steps. It keeps one status row below build output in an interactive terminal, showing each build's state and elapsed time. Redirected output gets periodic plain lines. Replacement uses the incremental release profile for web builds by default; BLOCKLOOM_WEB_PROFILE=dist just replace requests the fat-LTO shipping profile. just web-player still defaults to dist. Web builds compile only the library that wasm-bindgen loads, skipping the unused runtime executable.

Build recipes use scripts/prune-target.py before and after compilation to keep the whole target tree within a 20 GiB budget. Python 3 is required. BLOCKLOOM_TARGET_LIMIT_GIB changes the budget. Cleanup evicts old incremental caches first, then older profile caches, preserving binaries and staged players. It locks Cargo profile directories and skips active builds. Temporary overshoot and protected outputs are allowed; this is not a filesystem quota. Direct Cargo commands need just prune-target afterward. See README.md's Build Cache Budget.

Build the whole workspace, not just -p blockloom: off Linux (or with BLOCKLOOM_RUNTIME=process) the editor starts the blockloom-runtime binary sitting next to it, and a stale or missing runtime is exactly what cargo run -p blockloom would leave you with. The editor says so in a banner rather than letting Play do nothing.

rust-toolchain.toml pins the Rust version (rustup installs it on first build). There's no clippy.toml/rustfmt.toml - just cargo clippy/cargo fmt with defaults.

Style

  • NEVER use em-dashes (-) in comments, code, docs, or commit messages. Use a normal hyphen (-) instead.
  • Write comments like a human: short and terse, 1-2 lines. State what and why briefly. No multi-paragraph essays, no history lessons ("Previously..."), no dangling "see X docs" pointers unless X exists.

Frontend (blockloom-qt/qml/, QML)

The QML is compiled into the binary by blockloom-qt/build.rs (cxx-qt's CxxQtBuilder + qmlcachegen), so a plain cargo build picks up every edit. It needs Qt 6.12 or newer with Quick, QuickControls2, QuickDialogs2 and Multimedia; use Qt 6's qml/qmlls (/usr/lib/qt6/bin on Arch), not Qt 5's. A new .qml file must also be listed in build.rs's QmlModule.

For live edits, just qml-preview builds the workspace with the development-only blockloom/qml-preview feature and watches both QML modules through the selected Qt kit's qmlpreview. It needs Python 3. Use restart for structural edits that lose state; Rust/C++ changes and new QML files still need a rebuild. Run just build before installing or shipping. See README.md's QML Hot Reload section.

Local blockstitch development

blockstitch is a separate sibling repo shared with any other app built on the same block editor. Two halves of it are used here:

  • crates/blockstitch-core, the Rust backend (value system, document model, editor operations);
  • the QML block canvas and controls (blockstitch-qml at the repo root, module com.blockworked.Blockstitch), which lives on blockstitch's qml branch.

Both are git dependencies in the root Cargo.toml, pinned to the same commit. To build against a local checkout instead:

just blockstitch-local [path]        # default path: ../blockstitch, relative to the repo root
just blockstitch-published [commit]  # restore the pinned commit, or move to a new one

AI/CLI shell (blockloom-shell)

A standalone text interface that speaks every Backend::dispatch command as a single text line, returning JSON responses. Useful for AI agents, scripts and manual testing outside the GUI.

just shell                              # builds workspace, launches REPL
just shell --eval 'create-project name=Demo mode=TwoD'   # one-shot
echo 'add-actor name=Ball shape=Circle' | just shell      # piped script

Syntax: command key=value key2="value with spaces" - bare words and numbers keep their type; quoted strings and JSON values ([1,2], {"a":1}) are preserved as-is. # comments, blank lines, and help / exit are understood. Every response is {"ok":bool,"result":...,"error":...,"state":...}.

Architecture: a separate binary (blockloom-app/src/bin/shell.rs) that hosts its own Backend. The command registry lives in blockloom-app/src/shell.rs (COMMANDS), one CommandSpec per dispatch command. parse turns a line into Action, run turns Action into a JSON response. --eval for one-shot, --no-state to suppress the state snapshot (useful for bandwidth with large projects). Piped mode exits 1 if any command errors. Project folders carry an owner lock (blockloom-core/src/sync.rs) and a revision counter bumped on every save: open-project attaches to a live owner's copy (or takes it over with force), idle backends reload the folder's saves before each command, sync-status / reload-project / take-over-lock manage the rest, and --watch streams revisions. --attach drives the editor's own backend over the attach socket (blockloom-app/src/attach.rs, served by the editor) so there is one copy at all.

MCP server (mcp/)

AI agents can also reach this through the Model Context Protocol: mcp/ is a pnpm/Node package (@blockworked/blockloom-mcp, entry src/index.ts) that spawns blockloom-shell --no-state and exposes every --specs command as an MCP tool, with schemas derived from the same registry (see src/registry.ts, which maps every ArgSpec.ty prose string to a zod schema - a new prose type must be taught there). Tool calls are one shell line, responses are the {ok, result, error} shape, plus blockloom://state and blockloom://blocks resources. A project's plugin commands are tools too: after open-project, plugin-* and undo/redo the server re-reads plugin-commands and registers (or removes) one tool per command, named plugin-id__name with a schema built from the command's typed arguments (src/plugins.ts; a new plugin field type must be taught there). Each session is its own backend unless started with --attach (see above), so without it the window-and-shell sharing rules apply: the shell attaches to the live owner's files and follows their saves, and one MCP server process and the editor should still not write the same project at once. The shell resolves as target/debug|release/blockloom-shell beside the repo, BLOCKLOOM_MCP_SHELL, or --shell; just mcp builds everything and prints the client config line for the built dist/index.js. Build/test with cd mcp && pnpm install && pnpm run build && pnpm test.

Architecture

Cargo workspace

The editor, and the game world. On Linux the world runs on a thread inside the editor and draws straight into its Game view (see Game view below); on Windows it runs on a thread too, but reads its target back and the view uploads those bytes as an image. Elsewhere it is a child process.

  • blockloom-qt (package blockloom) - the editor window and the app's entry point: Qt Quick over cxx-qt. It owns blockloom-app directly - there is no daemon. src/app_bridge.rs is the one QObject QML talks to: invokeCommand runs a Backend::dispatch command on a worker thread, in order, and answers with replied(token, {ok, result, error}); state the backend publishes lands in the stateJson property, coalesced per burst. src/preview.rs follows a child runtime's MJPEG preview stream into previewFrame. src/game_view.rs/.cpp are the in-process Game view, and src/pointer_lock.cpp its pointer lock.
  • blockloom-app - the backend: src/commands.rs holds every command, src/dispatch.rs maps command names + JSON args onto them, src/state.rs holds SharedState/AppState and the snapshot the frontend gets, and src/runtime.rs is the game runtime's leash. No Qt dependency. Commands publish changes through AppHandle, a plain callback the host supplies.
  • blockloom-runtime - the game world: a Bevy app that renders one project and runs its blocks. A library plus a thin binary: run_process is the child process and the built player, embed::run the windowless in-editor world, and both build the same world through add_world. --mode 2d|3d decides which physics/render pipeline is built, so the editor restarts it when a project switches dimension. src/world.rs holds the dimension-agnostic systems, src/dim2.rs/src/dim3.rs the sprite/bevy_rapier2d and mesh/bevy_rapier3d halves. The same binary is what a built game ships: with a pack beside it, it loads that instead of waiting for an editor, and src/player.rs is the whole of the difference (see Building a game below).
  • blockloom-protocol - the messages between them: newline-delimited JSON over a child's stdin/stdout, or the same enums over channels in-process. No sockets, no ports; the pipe or channel closing is the whole shutdown handshake. Bump PROTOCOL_VERSION whenever a message changes.
  • blockloom-core - the engine library both processes share: scene.rs (the value types a world is built from - looks, bodies, placements, camera), components.rs (what an actor is made of - see below), blocks.rs (the block vocabulary and its BlockKind impl), fields.rs (what the frontend calls each value slot), project.rs (the saved document and the folder it lives in), library.rs (the project folders the Dashboard lists), assets.rs (the files inside one of those folders, which the asset tray manages and the runtime loads images and fonts from), pipeline/ (what import makes of each of those files - see Import roles below), pack.rs (that document again, as a built game carries it) with build.rs (what lays a build out), vm/ (the block VM), codegen/ (the same blocks as Rust instead), script/ (compiling a project's Rust scripts, and the ABI they talk over), sense.rs (the world state reporter blocks read), ui.rs (the screen-space interface a game builds out of blocks - see Interface below), and wire.rs (the one shape difference between documents and the frontend).
  • blockloom-plugin-api, blockloom-plugin-host, blockloom-plugin-sdk, blockloom-plugin-gpu - the plugin platform: manifests, schemas, records and the C ABI (api, wasm-safe), resolver, cache, install transactions and the native loader (host). See Plugins below.
  • blockloom-physics-probes - not part of the game: Phase 0 of the physics and character controller plan (docs/physics-and-character-controller-plan.md). Test-only probes against the pinned Rapier (rapier2d/rapier3d at the rev bevy_rapier3d uses) answering the rows of docs/physics-compatibility-ledger.md: materials.rs (static/dynamic friction through a modify_solver_contacts hook), compounds.rs (ownership, mass), events.rs (the Unity pair matrix, per-step event delivery), queries.rs, ccd.rs (what each CCD flag set holds) and controller.rs (Rapier's KinematicCharacterController against Unity's skin width, step offset, slopes, overlap recovery and platform carry). Findings to keep in mind before touching physics: queries are stale until detect_collisions runs, and syncing a never-stepped velocity-based kinematic body freezes it until wake_up; Rapier's offset is Unity's skin width but its autostep is not the step offset, and overlap recovery is capped per call; parry's capsule-vs-box intersect_shape/contact flicker within a centimetre of touching (use distance); Rapier's own kinematic carry lags and double counts with an explicit one. Unity values come from Unity's documentation, not an editor. CI runs it with the rest of the workspace; cargo test -p blockloom-physics-probes -- --nocapture prints the quoted numbers.
  • blockloom-net - native multiplayer transport (Phase 0 of docs/multiplayer-and-embedded-server-plan.md, findings in docs/multiplayer-phase0.md). Quiche over a nonblocking UDP socket, driven by poll() from one thread, carrying opaque bytes: Server and Client with ALPN blockloom-game/1, a self-signed in-memory Identity pinned by its SHA-256 Fingerprint (a BoringSSL custom verify callback, never a disabled check), stateless Retry address validation, reliable streams with a 4 MiB per-peer queue cap (QueueFull), datagrams, a peer cap and kick. Impairment (loss, duplication, delay, jitter, seeded) wraps the send path for tests, and Quiche's pacing times are honoured there. Server connection IDs must be full length (a short header does not carry one). Nothing depends on it yet and it cannot build for wasm32 (BoringSSL), so keep it out of the web and Android runtimes. cargo test -p blockloom-net; the RTT/loss matrix is cargo test -p blockloom-net --test loopback -- --ignored --nocapture matrix.
  • blockstitch-core (sibling repo, see above) - the shared block-editor backend. value is the Value/Op expression system, extended by an app through register_operators (Blockloom registers its sensing reporters in blockloom-core/src/value.rs); graph is the document model (BlockGraph of strands, instructions, comments, variables, custom blocks), generic over the app's own instruction enum via the BlockKind trait; editor is canvas addressing (PathStep/ValueLocation), undo History, and every structural edit a drag/drop/typed character performs. Blockloom implements BlockKind for InstructionKind (blocks.rs), names its value slots in fields.rs, and gives every actor a BlockGraph of its own.

Coordinates and units

World space is right-handed and Y-up in both dimensions (Bevy's, which glTF shares, so models import without a flip). Rotations are degrees, positive counterclockwise about +Z in 2D; a 2D actor faces +x and a 3D one -Z (a spot light's forward). A 2D z is only draw order (Place z plus the Render layer) and means nothing to physics.

The scale is the one deliberate difference: 3D is in metres, 2D is in pixels, a hundred to the metre (scene::PIXELS_PER_METRE, also rapier's physics scale in 2D). At zoom 1 one 2D unit is one logical screen pixel (Camera2d's projection scale is 1 / zoom), so a sprite's size is its image's size and pixel art lines up. That is why 2D defaults are bigger numbers: gravity -981 against -9.81, a 60 px starter player against a 0.5 m ball, PIXELS_PER_CELL_UNIT 16, audio falloff 1/500. Physical settings (gravity, wind speed, fetch for the sea state) are in world units a second, so in 2D they read in pixels; metre-based maths (sea_target, thunder delay) divide by PIXELS_PER_METRE first.

Why it isn't Unity's or Unreal's: 3D already is Unity's (1 unit = 1 m, Y-up), and Unreal's centimetres and Z-up would only add a conversion at every glTF and Bevy boundary. Unity's left-handedness is not worth fighting Bevy's maths and glTF for. For 2D, Godot, GameMaker and Scratch work in pixels too; Unity's 2D is the outlier (metres plus a pixels-per-unit on each sprite), and moving to it would change every saved position and size, plus numbers typed into blocks and scripts that can't be migrated automatically.

Crossing dimensions converts lengths and nothing else. Mode::length_from is the arithmetic; Actor::convert_units applies it to an actor's position, look, parent offset and joint, and runs on a scene's switch_mode and on Persist survivors carried by switch scene to (their live position is in the old scene's units). Not converted, since the right answer isn't a plain scale: tilemap tile sizes (32 px by default, never under 1), emitters, water, volumes, rooms, parallax and the 3D camera component. Re-tune those after switching.

Components

An actor is a list of components, not a fixed set of fields (blockloom-core/src/components.rs). Place, Look, Render and Body each become the Bevy component they name when the runtime spawns the actor, so removing Body really does leave it without a rigid body, and removing Look leaves a positioned, scriptable actor with nothing to draw. Only Place can't be removed: there is nowhere for an entity without a transform to be. actor.visual() is therefore an Option, while placement(), physics() and visible() fall back to a default.

Four components have no fixed-field ancestor. Camera attaches the world camera to that actor - follow, first person or third person - and one project has one of them, so adding it takes it off whoever had it. Script names a Rust file (see below). Parent names another actor this one hangs off (see Actors below). Custom is a named bag of values the project invented (Health { hp, armour }); the runtime carries it on the entity as CustomComponents, publishes it through sense::ActorSense, and set <field> of <component> to writes it back. Like a position and unlike a variable, those writes last exactly as long as the run.

Components come and go mid-run, from a block (attach/detach) or from a script. engine.attached is the one record of what an actor is carrying right now - the document says what it started with - and world::apply_component_effects is the only place that writes it. A Body or a Look needs the dimension's own pipeline, so dim2/dim3 pick those two out of the same effect list and do the ECS half. Re-attaching brings back what the editor authored, or that component's defaults if the project never had one.

Pre-component documents kept visual/placement/physics/visible flat on the actor, and the world camera named the actor it followed. Both still load: Actor deserializes through ActorRepr, and Project::normalize moves the old follow onto its actor as a camera component.

Physics document (Rigidbody, Collider)

Phase 1 of the physics plan, blockloom-core/src/physics/. It is the saved model and its rules. Actors with a legacy Body still play through the old path (unchanged, byte for byte); actors with Rigidbody/Collider components play through the Phase 2 installer below. Nothing converts a project until a later phase wires migration into the editor, and an actor may not have both.

  • ActorComponent::Rigidbody { rigidbody } (RigidbodySpec: body type, mass or density, damping, gravity, interpolation, collision detection, constraints, velocity caps) and ActorComponent::Collider { collider } (ColliderSpec: a ColliderGeometry of a saved ColliderShape or FromLook, center, rotation, material, trigger, layer, overrides). One body per actor; colliders repeat, so Components::collider(id)/colliders() address them by ColliderId and the name-based get/remove refuse an ambiguous repeated name. Ids are generated, unique per scene, and kept by undo (snapshots), save/load and reparenting; Actor::refresh_physics_ids gives a duplicate its own. A capsule's height is end to end.
  • Ownership (ownership.rs, PhysicsOwnership::resolve): a collider belongs to its own actor's Rigidbody or the nearest ancestor's (via Parent); no body above it makes it static; a nested Rigidbody starts a separate body. The shape's pose in the body's frame is derived from the same world matrices place_authored_children builds, honoring Parent offsets. Static colliders need no Look and a body may have no shape; both are valid.
  • Settings and materials live on Project.physics (PhysicsSettings: schema version, CompatibilityProfile Legacy or Unity, MaterialLibrary), written only when not default so an untouched legacy project re-saves byte for byte. A newer schema_version fails to load with a clear error. Built-in materials (Default, Ice, Rubber, No Bounce) are referenced by name; stored ones by MaterialRef::Asset. 3D combine priority is Maximum, Multiply, Minimum, Average; 2D uses the geometric mean of frictions and the larger bounce.
  • meta.rs holds one property table per component (unit, bounds, world, advanced); a test keeps it in step with the serialized defaults, so a new field needs a row. validate.rs checks specs and a whole scene (shape in the wrong dimension, a solid concave shape on a dynamic body, a trigger that is also one way, an unresolved material, a legacy Body beside the new components, ...).
  • Edits (edit.rs) run on one actor, validate and roll back that actor on failure; only errors on the edited actor or its touched ids refuse an edit, so a hand-edited bad actor elsewhere does not lock the scene.
  • Migration (migrate.rs): Scene::physics_migration_preview shows what each legacy Body becomes (Rigidbody plus FromLook collider plus a stored legacy material, ids {actor}-rigidbody/{actor}-collider); migrate_physics applies it idempotently. Only the preview is exposed (physics-migration-preview) because the runtime cannot read the result yet.
  • Commands (blockloom-app/src/commands/physics.rs): add-collider, set-collider, remove-collider, fit-collider-to-look, set-rigidbody, remove-rigidbody, set-physics-profile, add|set|remove-physics-material, and the reads physics-check, physics-ownership, physics-properties, physics-migration-preview. A refused edit leaves no undo step. The generic add|set-actor-component refuse these two components and point at the typed commands. The wire spelling is {"kind": "Shape", "shape": {"kind": "Sphere", ...}}.
  • Not yet: any UI (the inspector shows an empty card for these components) and verified Unity defaults for the Blockloom-chosen values (DEFAULT_MAX_LINEAR_VELOCITY and the sleep and solver defaults). Tests: cargo test -p blockloom-core physics (tests/physics_gate.rs holds the gate fixtures) and cargo test -p blockloom-app --test physics.

Physics runtime (bodies, shapes, filtering)

Phase 2 of the physics plan. Core decides, the runtime installs.

  • Core (blockloom-core/src/physics/): plan.rs PhysicsPlan::build(actors, mode, settings) (also Scene::physics_plan) turns a scene into BodyPlans and ColliderPlans and carries every problem (issues, a superset of validate_scene). geometry.rs applies Unity's scale rules (a box scales per axis, a sphere by its largest axis, a capsule's radius by the larger of the two axes across it and its length by its own axis; negative scale only mirrors) and never turns a primitive into a hull. layers.rs is the 32-layer matrix per dimension (LayerSettings, saved on PhysicsSettings.layers, written only when changed) plus ColliderFilter/pair_collides: a collider's include/exclude overrides decide first (exclude beats include, the higher priority wins between two, a tie excludes), then the matrix, then each side's legacy mask. needs_exact says when two group masks cannot say it (an include, or an exclude with a priority) and the plan sets exact_filtering. ops.rs is the ForceMode math (every mode reduces to an impulse for one fixed step, so a force never lingers).
  • Mass: shapes carry it. An explicit body mass becomes density mass / volume over the shapes that count (enabled, not triggers, with volume), so the total is exact whatever the shape count and COM and inertia come from the shapes. A body with nothing to carry it holds its own mass (ExtraMass; default 1 kg). A custom inertia (or center of mass, which needs an inertia too, else an error) moves the whole mass onto the body. Static and kinematic bodies carry no density.
  • Positions: a collider's pose is solved in its frame actor's space (the body actor, or its own actor for scenery) from the same world matrices ownership uses, in metres (position) and as the local translation under the frame entity (local, which bevy_rapier multiplies by the frame's scale). A rotated shape under a stretched frame would shear and is an error. Zero scale is an error.
  • Unsupported on purpose (errors, not silent): convex hull, triangle mesh, terrain and tilemap colliders (Phase 3: cooking), a polygon that is not convex, a shape for the other dimension, a concave solid on a dynamic body, a custom center of mass without an inertia. A collider's contact_offset is Rapier's ContactSkin (times pixels per metre in 2D), queryable filters ray/cast/overlap queries, and a body's interpolation is a PoseSmoothing component read by world::blend_pose (None draws the fixed-step pose, Interpolate blends, Extrapolate carries the last step on; an actor with no Rigidbody keeps blending, and migrated bodies are Interpolate).
  • Runtime (blockloom-runtime/src/physics_install.rs): install runs at the end of rebuild_world for the scene's plan (an erroring plan installs nothing and logs). A body gets RigidBody, Velocity, ExternalImpulse, gravity scale (Use Gravity off is scale 0), damping, LockedAxes, Sleeping, CCD, extra mass and RigidBodyDisabled for Simulated off. Each collider is its own child entity of its frame entity (PlannedCollider), with ColliderScale::Absolute (the shape is built at its final size), density, Friction/Restitution, collision groups, Sensor for triggers (ActiveCollisionTypes::all() - STATIC_STATIC), ColliderDisabled, and one-way (2D). Removing a collider entity is how a shape goes away; refresh_masses nudges every planned body so the mass totals again (Rapier keeps the old one otherwise). Contacts on a collider entity are tracked as touches on the actor that owns the component (see Contact lifecycle below).
  • Hooks: 3D uses Hooks3 (RapierPhysicsPlugin::<Hooks3>) for the stick/slip friction (static friction below STICK_SPEED of sliding at the contact point, dynamic above; only on colliders whose two coefficients differ) and the exact pair test; 2D reuses OneWayHooks (now also the pair test). 2D friction is Rapier's geometric mean and bounce its maximum, set per collider. PhysicsLayers is the resource the hooks read. Velocity caps and frozen axes are enforced by clamp_velocities before each step (a 2D body has no angular cap; Unity 2D has none comparable).
  • CCD: Discrete and Continuous both leave Rapier's own sweep against fixed colliders (a true Discrete would need max_ccd_substeps = 0, a world switch, so it is not applied while legacy bodies exist); Continuous Dynamic is Ccd; Speculative is SoftCcd with a 0.5 m prediction. See the ledger's CCD table for what each holds.
  • Solver overrides: solver_iterations becomes Rapier's AdditionalSolverIterations on the body (Rapier's solver has no per-body iteration count, so this is extra substeps, not a Unity iteration count). max_depenetration_velocity caps how fast a contact pushes the body out of an overlap: Rapier's soft contacts correct position at the world's normalized_max_corrective_velocity (3 m/s) without touching velocity, and the per-body cap clamps each SolverContact.dist to at least -cap * dt in the modify_solver_contacts hook of both dimensions (depenetration_cap, the slower of the two bodies wins). A cap at or above 3 m/s changes nothing and installs no hook.
  • Force blocks: add force and add torque (Action::AddForce, Effect::AddForce, Act::AddForce, ACT_ADD_FORCE 123 in the logic ABI and 101 in the script ABI, a script's add_force/add_torque) pick a ForceMode and a vector. d2/d3::apply_forces reduces the mode to one impulse for the fixed step (ForceMode::linear_impulse_array, torque_impulse_3d/2d, with the body's mass and world inertia from ReadMassProperties) and adds it to ExternalImpulse, so nothing lingers. Only dynamic bodies answer; the 2D world is in pixel units, so a 2D impulse is not rescaled.
  • Contact lifecycle (physics/contacts.rs, blockloom-runtime/src/contacts.rs): ContactTracker is pure core. The runtime's track_contacts (dim2/dim3, in FixedPostUpdate after the writeback) feeds it Rapier's Started/Stopped reports once per fixed tick (sensor pairs are ContactKind::Trigger, with no payload; solid ones carry normal, points, relative velocity and impulse read from the contact pair), closes the tick and queues Enter, Stay (at most once per pair and tick, while either body is awake) and Exit in tick/pair/phase order. step_vm hands the VM what earlier ticks made (world::deliver_contacts), so an event made in tick N is heard in N+1 whatever the render rate. A pair is keyed by its two collider ids; the actor-level answer is a refcount over pairs (touching, pairs_between, mirrored into engine.touching for the touching? reporters), and ContactEvent::edge marks the actor-level transition that when I touch listens to, so a compound wall is one Enter and one Exit. A pair that ends because an actor was deleted (delete_actor) or a collider was removed/disabled (Rapier's REMOVED flag, ColliderDisabled) or a filter changed (engine.filter_touched) gets an Exit with an ExitReason; a run reset clears the tracker without delivering. when I touch gained phase (Enter/Stay/Exit, default Enter) and scope (Any/Collision/ Trigger, default Any) so old documents behave as before; scripts hear Event::Contact (EVENT_CONTACT 21) for every phase. Still open: the shape-replacement and teleport reasons (a replaced collider entity ends its pairs as Separated).
  • Legacy effects: world::is_dynamic also recognizes a simulated dynamic Rigidbody, so push, set velocity and the like act on planned bodies. The navmesh and fracture still read the legacy Body only.
  • Commands: set-physics-layer-name, set-layer-collision, physics-plan; physics-check now includes plan-level errors, and Play and Build refuse a scene with a physics error (commands::physics::preflight).
  • Tests: cargo test -p blockloom-core physics, cargo test -p blockloom-runtime --lib physics_install (headless mini-apps with a real Rapier world: resting, mass totals and removal, modes, triggers, layers, exact filtering, stick/slip on a slope, CCD, one-way, caps, forces, depenetration caps, contact events at two frame rates), cargo test -p blockloom-app --test physics.

Physics queries (Phase 3)

physics/query.rs holds the request types (QueryRequest: ray, cast and overlap of ball, box and capsule, closest), QueryFilter (layer mask, trigger policy, ignored actor) and QueryOutcome. The runtime answers them from the live Rapier world (blockloom-runtime/src/queries.rs) through a QueryService installed for the frame. A query block (CastRay, CastBall, OverlapBall, FindClosest) asks on the spot as the running actor and files the answer as that actor's query result; QueryNumber/QueryText read hit N back by field (a miss reads zero or empty). The VM, compiled logic (ACT_PHYSICS_QUERY is the script ABI's) and scripts (ABI_VERSION 39, LOGIC_ABI_VERSION 36) land on the same answers; tests/codegen.rs holds them together with a one-wall harness world. RayHit/RayDistance/CircleHit reporters ask the same service. With no world installed a query reports an error and reads as a miss.

Physics inspector

RigidbodyForm.qml and ColliderForm.qml are the cards for the two components (InspectorPanel.qml loads them; a collider is removed by id with remove_collider, the Rigidbody with remove_rigidbody, and Add component gives an actor a Rigidbody or another Collider). Every edit sends the whole next spec to set_rigidbody/set_collider, so validation and undo are the backend's. QML test: tests/qml/tst_PhysicsInspector.qml.

Character controller (Phase 4)

A CharacterController component (ActorComponent::CharacterController, physics/controller.rs, CharacterControllerSpec) is a capsule that blocks sweep through the world, Unity-style: radius, height, centre, slope limit, step offset, skin width, minimum move, detect collisions, overlap recovery, layer and up. It needs no Rigidbody (the runtime gives the actor a position-based kinematic body when it has none). Scene::set_character_controller/remove_character_controller edit it and the plan carries PhysicsPlan::controllers; the shell has set-character-controller and remove-character-controller.

  • Contract: core decides, the runtime installs. controller::move_call(actor, MoveMode, vector) runs through a ControllerService (installed per frame by controller::with_service, the way queries are). Move is a displacement without gravity, Simple a speed with gravity (fall speed builds while airborne and is zeroed on a floor or ceiling). Moves run in order on the spot with a pending displacement; the pose is written once per fixed step by blockloom-runtime/src/controller.rs::apply_motion. A move below min_move_distance skips moving but still probes the ground and recovers overlap. Hits are classified by normal against the slope limit into sides, above and below (CollisionFlags, Unity's 1/2/4); triggers are never obstacles.
  • Runtime (blockloom-runtime/src/controller.rs): KinematicCharacterController (move_shape) over a capsule child collider, 3D and 2D. Its hit normal points at the controller. ControllerAccess is folded into QueryAccess::scope, so a VM tick, compiled logic or a script can move a controller where it already asks queries. Fixed obstacles the controller meets fire Event::Collision (Enter, Stay, Exit by move); moving bodies are left to the contact lifecycle.
  • Blocks: ControllerMove (mode Move or Simple, x y z) and SetController (property, value) are both Action::Controller { op, vector }, Effect::Controller, Act::Controller (ACT_CONTROLLER 125 in the logic ABI, 103 in the script ABI) and controller::run_op, the one entry every adapter calls. ControllerNumber and ControllerText read (hit index, field) of the last move (grounded, flags, moved x, normal y, ...; see controller::read_number). Scripts get move_controller, simple_move_controller, set_controller, controller_number, controller_text and is_grounded (READ_CONTROLLER 55, TEXT_CONTROLLER 23). ABI_VERSION is 40 and LOGIC_ABI_VERSION 37. tests/codegen.rs holds the VM and compiled halves together against a flat-floor harness.
  • Inspector: CharacterControllerForm.qml (Add component gives an actor one; tests/qml/tst_PhysicsInspector.qml).
  • Tests: cargo test -p blockloom-core controller, cargo test -p blockloom-runtime --lib controller (headless Rapier worlds in 3D and 2D: floor, step, slope, wall, trigger, overlap recovery, idle then move).

Character motor and input actions (Phase 5)

A CharacterMotor component (ActorComponent::CharacterMotor, physics/motor.rs, CharacterMotorSpec) is the reusable gameplay layer over a CharacterController: walk, sprint and crouch speeds, ground and air acceleration, air control, turn speed, gravity scale, terminal speed, ground snap, steep-slope sliding, jump height, max jumps, jump cut, coyote time, jump buffer, crouch height and knockback drag. owner says who steers it (Player reads the project's actions, Script is blocks and scripts, Ai is for brains), space what a direction is measured against (World, Actor, Camera), and player which local player's actions drive it. validate and the set_character_motor edit refuse a motor without a controller, a second motor, or bad numbers.

  • Split: MotorState::plan is pure (intent and environment in, displacement and events out) and settle takes the move's hits back (landing, head hits, slope, sliding, support). motor::drive is the glue that calls the controller service (probe_move for crouch headroom, move_call(Move) for the displacement, a second call for ground snap). It lives in core so the runtime stays thin.
  • Runtime (blockloom-runtime/src/motor.rs): latch_player_input (Update, after publish_sensors) copies the Move/Jump/Sprint/Crouch actions (Move P2 for the second player) into the intent of every player-owned motor; drive_motors runs first in the fixed chain, before controller::apply_motion, with the camera's yaw, the actor's facing, platform carry (the support actor's movement since last tick, ignored past 5 m as a teleport) and turning towards the move at turn_speed.
  • Blocks: MotorAct (steer, jump, let go of jump, sprint/crouch on and off, push, stop) and SetMotor ride the controller op channel ("motor <op>" through controller::run_op, so no ABI change); MotorNumber/MotorText read the motor (grounded, speed, jumps left, jumped, landed, ...). Scripts get motor_steer, motor_jump, motor_sprint, motor_crouch, motor_push, motor_stop, set_motor, motor_number and motor_text. Events are exposed as one-tick reporter flags (jumped, landed, left ground, hit head); there is no when I land hat yet.
  • Input (input.rs): actions have a kind (Button, Axis, Vector2), a four-key composite, processors (dead zone, scale, invert, normalize), a map that can be switched off with set_map_enabled, and a player. InputBinding::MouseDelta binds look. InputConfig::add_player_actions adds the standard Gameplay map (Move, Look, Jump, Sprint, Crouch, Interact) and player_copy makes per-player copies. The runtime publishes each pad on its own, so a second player reads only their pad.
  • Inspector: CharacterMotorForm.qml. Shell: set-character-motor, remove-character-motor.
  • Tests: cargo test -p blockloom-core motor input, cargo test -p blockloom-runtime --lib controller_tests_2d (a headless 2D motor walking, braking and jumping).

Player camera, presets and profiles (Phase 6)

blockloom-core/src/player_camera.rs is PlayerCameraSpec, the ActorComponent::PlayerCamera that configures the actor's existing Camera (it never adds one): look sensitivity and stick speed, invert Y, pitch limits, turn_body, smoothing, wall avoidance (collision, radius, min_distance), scroll zoom, a 2D dead zone and look-ahead, and eye_follows_stance. The maths (look_turn, clamp_pitch, clear_distance, follow_2d, lead) is pure and tested in core. The generic add_actor_component/set_actor_component path carries it and refuses a bad setting by field name (check_player_camera).

blockloom-runtime/src/player_camera.rs installs it inside world::drive_camera. The stored rig.pitch is the look state, so set camera pitch blocks and look input share it; yaw lives in RigState. Mouse look applies only while mouse_locked; sticks use the "rightstickx/y" axes (0.1 dead zone). Third-person pitch is elevation. Wall avoidance is a swept ball QueryRequest::Cast from the pivot that skips the target and triggers. BodyFacing (actor to yaw) is how a first-person turn_body camera hands its yaw to motor::drive_motors. Engine.look_lock_offered: apply_cursor_lock takes the pointer once per run when a PlayerCamera with look sits beside a Camera.

blockloom-core/src/physics/presets.rs is PlayerPreset (first person, third person, top down 3D, platformer 2D, top down 2D). Scene::preview_player_preset lists what applying would add, replace, keep, remove or convert; apply_player_preset installs a visual, controller, motor, camera, player camera and the input actions in one undo step. A dynamic Rigidbody or legacy Body is a conflict that needs convert, and a refused apply leaves no trace. PlayerProfile (version 1) captures a finished setup as assets/profiles/<name>.profile.json and applies it to another actor.

Commands (dispatch, shell and MCP): preview-player-preset, apply-player-preset, save-player-profile, list-player-profiles, apply-player-profile, import-player-profile. QML: PlayerSetupCard.qml (under the Name row: preset, preview, convert, profiles), PlayerCameraForm.qml (the component card, "PlayerCamera" in Add component). Tests: tst_PhysicsInspector.qml.

Open: no collision gizmos in the scene view, profile instance overrides and reset-to-profile, QML not run here (no Qt), GPU paths not run.

Constraints and integration (Phase 7)

blockloom-core/src/physics/joints.rs is ConstraintSpec, the repeatable ActorComponent::Constraint (addressed by ConstraintId; the old single Joint component is untouched). Kinds: Fixed, Hinge, Ball (3D), Slider, Spring, Distance, Wheel, Configurable. An empty target anchors to the world. Both ends need Rigidbodies. Lengths are document units (2D pixels), angles degrees. With auto_configure the other end's frame is worked out from where the actors stand, so Play starts with no snap. blueprint() is the backend neutral recipe (locked axes, limits in radians, motors, coupled_linear), and plan_constraints validates and resolves frames into PhysicsPlan.constraints. Break force/torque thresholds snap the joint and may broadcast break_message.

blockloom-runtime/src/constraints.rs maps the recipe onto Rapier's GenericJoint, one child entity per constraint (an ImpulseJoint is one per entity). drive runs after PhysicsSet::Writeback: queued commands, rebuild on motor/spring change, break detection off ImpulseJointImpulses, and status (position, speed, force, torque) published for reporters.

Blocks/scripts use the op channel, so ABI_VERSION and LOGIC_ABI_VERSION are unchanged: JointAct lowers to Action::Controller op joint <verb>|<name> (controller::run_op routes the "joint " prefix) and JointNumber is an ExtOperator over joints::read_number. A name is the constraint's name or its 1-based place. Scripts: Actor::joint, joint_number. Commands (dispatch, shell, MCP): add-constraint, set-constraint, remove-constraint, list-constraints. QML: ConstraintForm.qml, "Constraint" in Add component.

Integration: a buoyant body is sized by its colliders (water/buoy.rs collider_extents), falling back to its Look, so it needs no Look. Fracture shards keep the source collider's CollisionGroups, SolverGroups, friction and restitution and share its body mass (destruction.rs Sources). A clone made mid-run gets its Rigidbody, colliders and constraints through physics_install::install_actor (fresh ids from Actor::refresh_physics_ids, constraints appended with constraints::install_more); its mesh colliders and CharacterController/Motor are not installed.

Physics Debug: SceneView.physics (PhysicsDebug: shapes, aabbs, contacts, joints, axes; PROTOCOL_VERSION 30) switches Rapier's own debug renderer (physics_debug::sync), off by default. The profiler gets physics/{bodies,active_bodies,sleeping_bodies,colliders,joints,step_ms}. The scene view has a box button that opens the panel (PreviewPanel.qml).

Open: no joint gizmo editing, no ragdoll handoff from animation, AI/navigation still drives its own movement rather than the motor, collision streaming is not separate from visual LOD, no single-step or fixture replay, no pair/contact counts in the profiler, QML and GPU paths not run here.

Upgrade, samples and shipping (Phase 8)

Docs: docs/physics.md (creator guide), docs/physics-api-coverage.md (what each surface does and what is open), docs/physics-compatibility-ledger.md.

Upgrading legacy Body actors: physics-migration-preview [actorId] previews across every scene; migrate-physics [actorId] converts them (Scene::migrate_physics or migrate_actor_physics) as one undo step and copies project.blockloom to .blockloom/backups/ first. A call with nothing to convert files no undo step and no backup. QML: PhysicsUpgradeCard.qml at the top of the Body card.

Samples: blockloom_core::physics::sample::build (physics-playground: player preset, crates, rope pendulum, motor wheel, 3D door, snapping weld, trigger zone, all components). create-project ... sample=physics-playground and the New Project dialog's "Start from" use it. Tests: core (plans clean in both dimensions, pack round trip), app (opens runnable), runtime (plays 240 ticks).

Packs carry the whole Project, so physics settings, components, constraints and cooked collision data ship in game.pack with no extra format.

Actors that come and go

An actor's id is what everything keys it by, and a run can mint ids the document never had. engine.spawned holds those runtime-only actors and Engine::actor looks there before the project, so one question finds any actor at all - a clone answers about its shape, its physics and its components exactly as the actor it was copied from does. Those ids are counted rather than random - ~1, ~2 - so one project run makes the same ids however it was scheduled, which is what lets tests/codegen.rs hold a compiled run's clones against the VM's. An id the host mints itself, for a script's clone in a build whose scheduler is the compiled program, is ~h1 instead, so the two counters can never land on the same name.

create a clone of copies a running actor. The VM does the scheduling half: register_clone gives the copy the template's compiled Program (an Rc clone - one program, many actors), its name, its custom-block inputs, and its own copy of the template's variables as they stand. It then queues Event::Cloned, which starts the copy's when I start as a clone strands at the top of the next tick - by which time apply_lifetimes has built the entity those blocks read through. The host does the world half: the clone is the template as the editor authored it, standing where the template stands now, carrying its live custom-component values and hanging off whatever it hangs off. A clone shares its template's name, so when I touch Ball, broadcast and how many Ball there are all reach every copy; the actor I made reports an id, which is how a block means one clone in particular. A compiled program does the same halves in the same order: its own Actors table mints the id and queues the copy, and the host is handed the entity to build (see Compiling the blocks below).

create actor makes something the document never had: a Place, a plain Look, and no blocks at all. delete takes an actor out of the run - the document is untouched, so Play puts an authored one back - and stops its scripts. Deleting yourself ends the strand that asked where it stands, the way stop all ends everything. Both blocks name an actor through a value slot, so the actor I made can be dropped straight into one; create a clone of names an authored actor, so it stays a dropdown.

The parent/child hierarchy is engine.parents, one entry per child, seeded from every Parent component on a rebuild and moved after that by set my parent to or a script. Rather than reparenting Bevy's transforms - which would make every position in the engine relative to somebody and leave rapier owning half of them - world::apply_parenting moves each child by exactly the change its parent underwent this step: child = (parent now / parent then) * child. A child that moved itself keeps that motion, a parent's turn swings its children around it, and parents are walked roots-first so one pass carries a move the whole way down a chain. It runs in FixedPostUpdate before record_poses, so the parent's change includes what physics wrote and the pose the renderer interpolates towards is where the child ended up. Loops are refused where they would be made - Project::prune_parents on load, commands::check_parent in the editor, world::set_parent at run time - because a cycle has no root to start the pass at.

A Parent may also carry an offset, which is where the child stands in its parent's frame. Place stays the one thing the world is built from, so the offset is resolved into a world placement before anything is spawned - world::place_authored_children, parents before their children so an offset down a chain is measured against a parent that has already moved - and read again whenever set my parent to hangs the actor off someone new, which places it at the offset rather than leaving it where it stood. A Parent without one - which is what a document written before offsets says - keeps the world position its own Place gives it, at build and at run time both. The local-position reporters (my local x position, <actor>'s local x position, and the matching script reads) answer that frame live: the parent's world transform inverted onto the child's world position, or the world position itself for an actor hanging off nothing.

Plugins

Design: docs/plugin-system-and-voxel-plan.md; decisions, measurements and deviations of the first implementation: docs/plugin-adr-0001.md.

  • blockloom-plugin-api has no Qt, Bevy or I/O: manifest.rs (plugin.json, tiers, capabilities, per-target runtime entries), schema.rs (component/resource/block/command/hook schemas, field validation, migrations), record.rs (PluginRecord), abi.rs (the C ABI v1) and id.rs. blockloom-core depends on this crate and never on the host, which has libloading and git and must not reach the wasm build. Bump versions::PLUGIN_ABI only when abi.rs changes.
  • blockloom-plugin-host is everything that touches disk or code: package (verify, seal, zip), source (path:, archive:, git:url#commit, registry:), registry (folder registries), cache (immutable shared cache), lock (plugins.json, plugins.lock, plugins.local.json, .blockloom/plugin-history), resolver, install (plan/apply/rollback), active (what a project loads, record audit, migrations, ship plan), hooks, lifecycle and native (library loader, capability gate).
  • Data: a plugin's data is an opaque PluginRecord in the document, either ActorComponent::Plugin on an actor or Project.plugin_resources. Core never reads a payload, so a missing, newer or broken plugin loses nothing on open and save. ActivePlugins::audit classifies each record, and commands::plugins::preflight refuses Play and Build while one that is not editor_only has no working plugin, naming the record and the fix. A migration (plugin-migrate) is all records or none and snapshots first.
  • Commands live in blockloom-app/src/commands/plugins.rs: install, remove, update, pin, sync, rollback, registry, gc, seal, publish, migrate, check, list, plus add_plugin_component, set_plugin_component, set_plugin_resource, plugin_inspect (verify a folder and say what it is) and plugin_call. Package changes are transactions on plugins.json and plugins.lock only. A command a plugin declares is run as plugin-id/name key=value in the shell (Action::Plugin, forwarded as plugin_call) and through the MCP plugin-call tool. A command's action is add_component, set_field, set_resource, set_resource_field or module. StateDto.plugins is the snapshot slot.
  • Builds: a pack that carries plugins is PACK_VERSION 2 (without them it is still written as 1), each plugin's plugin.json and shipped files go to game/plugins/<id>/, and Android builds refuse plugins that have code. A web build ships portable ones (the page's mounted files hold plugins/<id>/, Launch::Web sends the same Plugins loadout for target wasm32-unknown-unknown, and blockloom_plugin_host::files::set_reader points package reads at vfs); a code plugin with no portable module is refused for web by the ship plan. The desktop player hosts that code: Launch::Player hands the world an EditorMessage::Plugins before Start, built by plugins::shipped_loadout from the pack's PackedPlugins through blockloom_plugin_host::shipped (Package::load_shipped checks the manifest against the pack's content hash and each shipped file against its declared hash; a missing or altered file, or no artifact for the target, stops the game with the reasons, since it must not run without its plugin). A Build accepts plugin blocks the world can run itself (block_runs_in_world: a module-op statement or reporter, any hat) and refuses a statement whose command runs in the editor. Plugin blocks compile too (see below), so a build keeps native logic.
  • Native modules load in the editor on a command's first use (commands::plugins::Modules on OpenProject, keyed by package hash, dropped when the package changes or the project closes): a module command action calls NativeModule::call_json, the module's logs go to the run log, and a panic is an error, not a crash. A module may be called from any thread but one at a time, hence the Mutex.
  • Portable modules (runtime.portable, blockloom-plugin-host/src/portable.rs, contract in blockloom-plugin-api/src/wasm.rs) run a .wasm through wasmi with portable dispatch: one capped memory, only the blockloom.log/call imports, and call_limit_ms * FUEL_PER_MS fuel per call. A call that runs out or traps stops the module and Modules::get loads a fresh one next time. Modules holds a Module::{Native, Portable}; a plugin with a native library for the target uses it, else its portable module. Editor only so far.
  • Plugin statement blocks: the document holds one generic InstructionKind::PluginBlock { plugin, block, args } (args in the block schema's slot order, FieldId::PluginArg(i)), compiled to Action::PluginCall and run by the VM as Effect::PluginCall, whole numbers as JSON integers. The world forwards it as RuntimeMessage::PluginCall, and the editor runs it (commands::plugins::run_block: slots named by the schema, an actor argument filled from the running actor, an actor slot's name resolved to an id, then the same plugin_call path). Codegen emits it as Act::PluginCall (slots read into lets in order, then one host act; over the logic ABI as ACT_PLUGIN_CALL with the slots as a JSON array in c, whole numbers as integers, which logic.rs turns back into the same Effect::PluginCall), and preflight stops Play on a block no installed plugin provides or whose slot count changed. A Build is refused for a statement whose command runs in the editor, since a built game has none (module-op blocks ship, see Builds). plugin-run-block runs one from the shell and MCP, and the snapshot's plugins.blocks lists every available block. The palette draws the statement ones (Blocks.qml's plugin section, grouped by category): the one PluginBlock row's head is a function of the instruction that splits the schema's label into label pieces and value pieces (key: "args", index: i, field PluginArg:i), cached so the canvas keeps its controls. head as a function and index are blockstitch row features.
  • Plugin code in the game world (blockloom-runtime/src/plugins.rs, blockloom-plugin-host/src/world.rs, blockloom-plugin-api/src/loadout.rs). Play sends EditorMessage::Plugins { loadout } between Load and Start (PROTOCOL_VERSION 26): per plugin its code runtime, hooks and the blocks whose commands are module ops (ActivePlugins::loadout). world::begin_run opens each module (WorldPlugins::load) and calls world.start with the plugin's records and resources; end_run calls world.stop and drops them, so a module lives exactly as long as a run. Hooks are named by a Stage (Input, PreSimulation, FixedSimulation, EffectApplication, PostPhysics in the fixed step, RenderExtraction and Presentation per frame), ordered by their before/after, and called once per stage as op hook.<name> with {stage, hook, tick, dt}; a paused world skips the fixed stages. A module answers {"effects": [...]} of say, broadcast and error, which the world applies in order - a plugin never touches the world itself. An Unsupported answer means "nothing to do" (once, as an error, for a hook). A faulted portable module is dropped for the rest of the run. A plugin block whose command is a module op runs in the world (plugins::run_block); any other still goes to the editor as RuntimeMessage::PluginCall. The runtime's plugins feature (default) pulls in the host crate, which builds for wasm32 without libloading (a browser has no native libraries; the portable executor is wasmi running inside the player's wasm). The web player is built with --features plugins; Android builds without it and ignores the loadout. plugins/examples/tally answers world.stop with a final tally line. A plugin whose manifest says editor.preview is also hosted while nothing plays (plugins::preview, 3D only): the world opens it on Load, Plugins and the end of a run, starts it with preview: true and draws what it answers, but runs no hooks or blocks and drops says. The module is kept while its loadout and records are unchanged and started over when they change; Play replaces it with the run's own. The editor resends the loadout with every idle sync, so installing or removing a plugin updates the view.
  • Plugin reporters and hats are generic like the statement. A reporter is the value Op::Ext("PluginRead") (value::PLUGIN_READ) with args [plugin, block, ...slots]; its operator calls sense::plugin_read, which asks a thread-local reader that the world installs while a run has modules open (plugins::begin/end). The editor has none, so there a reporter errors with "only answers while the game is running". plugins::read turns the slots into JSON by the schema (an actor slot's name into its id), calls the block's module op as the asking actor (sense::current_actor) and takes the answer's value as the schema's returns type; a mismatch is an error. WorldPlugins::read remembers answers per module until the module is called for anything else or a frame starts (forget_reads, from the Input and Presentation stages), and a read may log but not act. A hat is InstructionKind::WhenPlugin { plugin, block, event, args }, compiled to Trigger::Plugin and started by Event::Plugin { plugin, event, args, actor }, which a module raises with an event effect ({name, actor?, args}). Hat slots are literal text, blank for any, and match as text or as numbers. Both compile: a reporter is the generic sensing read (sense(h, &me, "PluginRead", ..), answered by the host's ext_operator, so the world's installed reader answers it exactly as for the VM), and a hat is an Entry whose detail is plugin_detail (plugin, event and slots joined by U+001F), started by fire("Plugin", actor, detail, "") - with an actor only that actor's strand, without one every copy's - and matched as the VM does (plugin_hat_matches). tests/codegen.rs holds statement, reporter and hat against the VM line for line, and LOGIC_ABI_VERSION is 35. A script reaches the same three through the script ABI (ABI_VERSION 38): Actor::plugin_call(plugin, block, &[PluginArg]) is ACT_PLUGIN_CALL (c = slots as JSON), plugin_number/plugin_text are READ_PLUGIN/ TEXT_PLUGIN (a = plugin, b = block, U+001F, slots as JSON; answered by the thread's plugin reader, failures go to the run log), and Event::Plugin is EVENT_PLUGIN (subject the event, detail plugin then slot texts joined by U+001F), sent to every script or only the actor the event names. Project::plugin_blocks reports all three shapes (PluginBlockShape) so preflight checks each one (kind, slot count, and that a reporter's command is a module op), and a Build is refused only for a statement whose command runs in the editor. In the palette the operator PluginRead has a layout and result that are functions of the value, and the WhenPlugin header's head uses text pieces with an index (both blockstitch features). plugins/examples/tally has "tally of" and "when tally changes" blocks; its add raises changed once a run hosts it.
  • Plugin SDK (blockloom-plugin-sdk): a plugin author implements Plugin (start, then call over bytes or call_json over serde_json::Value) and names it with export_plugin!. The macro expands to the C entry symbol on a desktop target and to the portable module's four exports on wasm32-unknown-unknown, so one source builds as a cdylib or a .wasm with no pointers in sight. Host gives log and call (host services); an Error carries the Status the host sees and is logged. A panic is caught on native and answers Panicked; on wasm a panic hook logs it before the module traps. NativeModule::from_entry starts a plugin's entry function in-process, which is how the SDK's tests (blockloom-plugin-sdk/tests) and plugins/examples/tally run the real ABI without a library on disk. The tally example's package source is plugins/examples/tally/package; just example-plugin stages and seals it with its wasm into target/plugins/. A change to abi.rs or wasm.rs goes through the SDK too.
  • Plugin Manager: PluginManagerDialog.qml, opened from the top bar's plug button. It lists plugin_list/plugin_check/plugin_commands and runs the same plugin_* commands the shell does (install with a dry-run preview, update, remove, sync, undo the last change, clean the cache). A "Settings" section edits each plugin resource in place. It has QML tests (tests/qml/tst_Plugins.qml).
  • Schema-generated inspectors (PluginRecordForm.qml, PluginValueEditor.qml): plugin_list and the state snapshot's plugins.types list every component and resource type (name, displayName, kind, version, fields, defaults), built by types_json from the installed schemas. A form has a row per field and a PluginValueEditor per value (bool, int, number, text, color, vec3, choice, asset, actor, and list of any of those, recursively), clamped to the schema's bounds. An edit reports the whole next payload with any key the schema doesn't name kept, so an older plugin never trims a newer document. InspectorPanel.qml draws a Plugin component as that form (pluginCard), says so when the plugin is missing or the record needs a migration and keeps the data, and lists installed component types in Add component (plugin:<name> -> add_plugin_component). Editing goes through set_plugin_component/set_plugin_resource, so validation and undo are the backend's. QML tests: tests/qml/tst_PluginInspector.qml. A new FieldType must be taught to PluginValueEditor.qml (and src/plugins.ts). Drawing hints are declarative and never touch a payload: a field's ui (label, widget of slider for a bounded int/number or multiline for text, unit, step, visible_when of {field, equals | not_equals} or truthy) and a component's inspector.groups (label, fields, collapsed). check_ui in schema.rs refuses a hint that names a missing field, suits the wrong type, or draws a field twice; the form draws ungrouped fields first, then each group under a foldable heading, and hides a row while its visible_when fails (the value stays in the record).
  • Plugin panels (PluginPanelDialog.qml, Contributions::panels): a package's panels list a name, title and items - text, resource (a PluginRecordForm over one of its resources, written through set_plugin_resource) and command (a form over the command's arguments and a button that runs plugin_call). check_definition refuses an unknown resource or command. The snapshot's plugins.panels carries each panel with its owner and the commands its buttons run; the top bar shows a panel button while any exist. QML test: tests/qml/tst_PluginPanels.qml.
  • Plugin scene tools (ToolSchema, Contributions::tools): a package's tools name a cast module op, a reach, a command, that command's args ($hit.cell.0 reads the cast's answer, $option.name an option, anything else is text) and typed options. check_definition refuses an unknown command, argument or option. The snapshot's plugins.tools lists them and the scene view's toolbar gets a toggle per tool in 3D, with the options drawn by PluginValueEditor under it (kept in the QML Settings as JSON per tool). SceneTool::Plugin plus SceneView::plugin_tool (PluginToolView: plugin, tool, cast op, reach, outline, drag, option values; PROTOCOL_VERSION 28) tell the world which tool is out. A left click casts the pointer ray through the hosted preview module (plugins::tool_click, WorldPlugins::query: {x y z dx dy dz reach} in, {hit, ...} out, read only) and a hit goes back as RuntimeMessage::PluginTool; the editor resolves the arguments (ToolSchema::resolve_args) and runs the command (commands::plugins::run_tool, plugin-run-tool in the shell and MCP), one undo step, which reloads the world and so redraws the preview. A tool with drag paints a stroke: the button down casts every pointer move (plugins::tool_stroke, a cell once per stroke) and the release sends all the hits as one PluginTool, which run_tool runs batched as a single undo step (Escape or losing focus drops it). A tool with outline (a $-less path into the cast's answer holding [x0 y0 z0 x1 y1 z1], read by schema::box_at) gets a gizmo box under the pointer that follows the hover cast (plugins::tool_outline, edit::follow_plugin_tool). The voxel tools use both.
  • Mesh service (blockloom-plugin-api/src/mesh.rs, blockloom-runtime/src/ plugin_meshes.rs): a module answers with {"effect": "mesh", name, positions, normals, colors, indices, origin, emission, roughness, collider} (flat arrays, so any tier builds one) or {"effect": "remove_mesh", name}. The host checks a mesh (MeshData::check: array lengths agree, indices in range, finite, at most MAX_VERTICES) and reports a bad one as an error. plugins::apply queues a MeshOp on engine.plugins.meshes (a 2D game reports that only 3D can show one) and plugin_meshes::sync (Update, 3D) carries them out: one entity per (plugin, name) with Mesh3d, a standard material shared by roughness and emission (vertex colors multiply the base), and a fixed trimesh collider when collider is set. A same-named mesh replaces the old entity. mesh_visibility changes drawing and retains the collider, including on replacement; plugins::end queues Clear. These are not actors: no batching, LOD or occlusion, and nothing else reads them yet.
  • Voxel plugin (plugins/voxel, com.blockworked.voxel, phase 3's first slice): a finite world of cubes, SDK-built so one source is a native library and the portable module (just voxel-plugin seals the wasm). Its world resource (preset island|caves|flat|empty, seed, size, voxel size, origin, solid, palette colors/emission) is read at world.start; grid.rs holds 32-cell sections of u8 material ids with exact logical bounds and full-height 32 by 32 columns (air sections unallocated, a boundary edit dirties the neighbour), terrain.rs generates from hashes of each cell's own coordinates (the same seed always gives the same cells, trees and ore included), mesher.rs is the greedy cube mesher (visible faces only, faces across chunk borders ask the grid, each glowing material its own group), and lib.rs answers every op with the meshes that changed (chunk/x/y/z, and .../glow<id> for emissive ones, whose mesh emission is color times the palette's intensity) plus remove_mesh for ones that went away. Ops set, fill, sphere, generate (blocks and commands), reporters get/height, and rays in world units (ray.rs, a cell-grid walk: cast reports the cell, the empty cell before it, the face normal and distance; break and place edit along one); edits last for the run. The package schema declares the resource, commands and nine blocks (a material is a dropdown of the built-in names; ids work too). Measured: the default 64x32x64 island is drawn from the wasm module in under a second, inside its 10 s call budget. surface=smooth selects the CPU density mesher (smooth.rs): fixed tetrahedra, shared vertices and central-gradient normals across chunk halos. Signed density overrides in grid.rs are quantized to 1/256 cell; sphere brushes union or carve them, saved sphere lines replay them, and rays intersect the same triangles as collision (including actual shaped faces). Cube mode remains the default. Runtime edits can persist through save_voxels or persistent: true, using player storage and versioned compressed checkpoints. streamed: true uses bounded section residency around invoker components or stream_voxels, with horizontal column radius, vertical radius and a dense-cell byte budget; canonical queries and sparse edits survive eviction. gpu_meshing: true adds GPU vertex generation with CPU collision and fallback meshes. fracture_voxels detaches unsupported six-connected regions into editable convex bodies, retaining cells when fragment budgets are exhausted. See the voxel milestone in docs/plugin-adr-0001.md for settings, limits and validation. Render jobs stay in bounded 16-cell tiles; checkpoint v2 migrates v1 pages by cell coordinate. lod.rs caches derived samples at levels 1 through 4, bounded to 8192 records, with shape occupancy proxies, averaged smooth densities and edit invalidation along the ancestor chain. voxel_lod_sample inspects them in level lattice coordinates. lod_mesh.rs builds cube/smooth coarse CPU previews in 8-sample tiles, with two-sample halos, edit dependencies, a 65536-base-visit poll budget and four cached jobs. voxel_lod_mesh polls them until ready; these mesh data have no collision and do not replace full-detail renderer meshes yet. voxel_lod_select previews bounded perspective tile selection with frustum and distance checks and split/merge hysteresis. Camera inputs are explicit; selection does not publish meshes or change gameplay residency. Optional visual_lod uses presentation camera snapshots to stage a complete visual cut before replacing the previous one. Fine meshes are hidden through mesh_visibility, retaining their colliders; distant visuals have no collider. Pending cuts have a 2 MiB geometry budget and advance one tile job per frame. Edits invalidate intersecting halos, retaining unrelated tiles and jobs; generation changes restart jobs. lod_seam.rs clips visual tiles and emits planar solid-difference joins for mixed cube/shape/smooth resolutions, keeping boundary caps within the cut budget. Seam clipping advances one tile per frame. Renderer GPU mesh copies wait for a visible entity or instance, retaining hidden fine buffers and colliders. Ready copies use FIFO order under 64-mesh/10-MiB per-frame limits; CPU fallback remains until a whole-mesh copy completes. GPU cubes and rectangular cube LOD seams draw persistent eight-byte records through plugin_quads.rs (PBR/depth/shadow/motion/deferred), retaining only four vertex IDs and six indices per quad. Byte coordinates cover 128-cell LOD tiles; fractional/nonrectangular geometry keeps the CPU path. Smooth GPU meshes retain prefix sums and packed triangle spans; interpolation stays on the GPU. Visual cuts use 150 ms complementary dither crossfades, retaining one old cut. plugin_lod.rs traverses at most 2048 GPU-backed instances against the depth pyramid, requesting up to 512 deduplicated triangle uploads with two asynchronous readbacks. Stale/unsupported/overflow feedback falls back to CPU visibility. This does not select voxel tiles or request missing mesh jobs. Interpolated density seam collars and GPU voxel job requests remain unfinished. Run GPU tests serially. See docs/voxel-lod-research.md for the LOD implementation stages. Its manifest asks for editor.preview, project-storage and gpu-compute; preview does not load or write player saves. Saved edits are the world resource's edits lines (set X Y Z material, fill X1 Y1 Z1 X2 Y2 Z2 material, sphere X Y Z radius material), applied in order over the generated terrain at world.start (a bad line is reported by number and skipped), so the scene view and every run show them; add_voxel_edit appends one and clear_voxel_edits forgets them, both through the set_resource_field command action (sets one resource field, or with append pushes value onto its list; one undo step; a template builds the value from the command's arguments, sphere {x} {y} {z} {radius} {material}, so no value is needed). paint_voxel, paint_voxel_box, paint_voxel_sphere and paint_voxel_shape are those templates, and the package's four scene tools (paint, erase, ball, shape) click them into the world (see Plugin scene tools). A solid cell may be a slab, top slab, post, stair or ramp (Shape in shape.rs, a sparse table in grid.rs; a stair or ramp has a facing, the side it rises towards): the greedy mesher takes only whole cubes, shaped cells are a few boxes (and a wedge) meshed on their own from Shape::faces, with faces on a cell's edge hidden by a whole-cube neighbour, shape is the op, shape_voxel the command and block (ten blocks now), shape X Y Z name a saved edit line (stair west, ramp north). Writing a cube or air takes a shape away; cube-world rays treat a shaped cell as the whole cell, while smooth-world rays intersect its actual faces. The infinite preset (0.3.x) streams edgeless terrain: editable noise layers shape the land, biomes lines carve temperature/humidity rectangles with their own surface and hills, and features lines spawn ponds, trees and rocks, all canonical per cell so paging never changes the world. render_distance (default 256 blocks, steps of 32) drives the streamed radius, distant_chunks (default 128) drives the LOD reach, pregen_distant warms far tiles in budgeted slices. pages_per_tick limits nearest-first page work per call; portable infinite cube worlds process at most one 8-cell mesh tile, skipping empty tiles. chunk_workers does not launch concurrent workers yet. Terrain features are computed per column with a bounded 4096-column derived cache. Checkpoint loads reattach the generator configuration before sampling unloaded cells. The blocks list is the block registry: one JSON object per line (assets/blocks/*.json import into it) with id, color, textures and model (cube, shape or inline mesh with optional uvs); the cube mesher groups faces by material and per-face texture (top, bottom, side, all) with planar uvs, MeshData carries uvs plus a texture path the runtime loads once and shares, and registry names resolve everywhere a material does. Smooth terrain, coarse LOD, GPU meshing and fracture debris stay vertex-colored.
  • Importers and build hooks (blockloom-plugin-api/src/assets.rs, blockloom-plugin-host/src/imports.rs): a package's schema may list importers (name, extensions, limit_ms) and build hooks (name, limit_ms). Both are module ops over raw bytes, importer.<name> and build.<name>: a request is one JSON line (Request) then the source file's bytes, an answer one JSON line then each produced file's bytes in listed order (Produced, with warnings, errors and dependencies). The host does every read and write, so a module needs no capability: output paths are relative plain names (check_output_path), sizes are checked against the bytes, at most MAX_FILES/MAX_OUTPUT_BYTES, and a portable module gets the schema's limit_ms of fuel for the one call (CodeModule::call_bytes). An import of assets/x.gpl writes assets/x.gpl.imported/... (never imported again) and records source, output and dependency hashes in .blockloom/imports.json, so imports::status says fresh, source_changed, dependency_changed, output_missing, output_edited or source_missing; a re-import replaces exactly what the last one wrote, and deleting the source in the tray forgets it. import_assets runs the first importer that takes a file's extension (a failure is logged, the copy stays); plugin-importers, plugin-imports and plugin-import path=... importer=... are the shell/MCP side. Before a desktop or web build plugins::run_build_hooks runs every hook with the target and the asset list, stages the files under .blockloom/cooked/, and BuildOptions::extras (ExtraFile) copies them to game/plugins/<id>/cooked/ (refusing an escape or an overwrite); an error from a hook stops the build, and Android builds ship them in the APK's assets like desktop ones. plugin-reimport imports again what a source, dependency or output change left stale (an output edited by hand only when named); Play and Build call it first (plugins::refresh_imports, owner only), and the asset tray marks imported files and offers it. A package that is only importers and hooks is an editor tool and is not shipped. plugins/examples/palette imports GIMP .gpl palettes as a one-row palette.png (stored-deflate PNG, no compressor) plus palette.json, and its cook hook fails a build that has an unimported palette.
  • HTTP registries (blockloom-plugin-host/src/registry.rs): a plugins.json registry value that starts with https:// (or http:// for localhost, 127.0.0.1 and ::1) is an HttpRegistry, which reads a published registry folder (index.json, archives/) from any static host through ureq (not on wasm32). The index is read once per value, size-capped, and every archive is checked against the hash in it before it is unpacked, so only the index has to arrive over a trusted channel; an index naming a path that is not plain-relative is refused. open_registry(base, value) is the one place a value becomes a registry. Publishing stays a folder (plugin-publish): upload the folder. After a package change the owner bumps the folder's revision (plugins::reload(s, true)), so an attached copy's live reload also reloads its active plugins.
  • Trusted editor modules (PluginEditorsDialog.qml, blockloom-plugin-host/src/trust.rs): a package may list editor.modules (.qml files, which need the trusted-editor capability and are left out of every build). Their QML runs inside the editor with the editor's own access, so a module loads only after the user trusts that exact package. TrustLedger (<data dir>/blockloom/trusted-plugins.json, per user, never in the project) maps a plugin id to the package's content_hash; an update changes the hash and asks again. plugin_trust and plugin_untrust exist in dispatch for the window only: they have no shell or MCP command and the attach socket refuses them (GUI_ONLY). The snapshot's plugins.editorModules lists each such plugin with trusted, changed and its modules, and gives a module's file only while trusted. The top bar's app-window button opens the dialog, which asks for trust, lists the modules and loads one into a Loader with host as an initial property: host.plugin, host.project, host.resource(name), host.setResource(name, payload), host.call(command, args) and host.app (the editor's invoke and state, i.e. host-level). plugins/examples/com.example.notes is the example. QML test: tests/qml/tst_PluginEditors.qml. A package may also list editor.inspectors ({component, module}, one .qml per component, same capability and trust): InspectorPanel.qml's pluginSection loads it in place of the schema form once the package is trusted (the form stays otherwise), with host.write(next), host.call(...), host.payload, host.type, host.app and host.project. com.example.notes has a sticky component with one. Native compiled editor modules are deliberately not offered: they would run host-level code inside the Qt process with no ABI to bound it, so a plugin that needs custom UI uses trusted QML.
  • Plugin code on Android: Android loads only portable (wasmi) modules, like the browser (loads_native_libraries is false for both). The runtime .so is built with --features plugins, the plugin host skips ureq there, builds ship plugins in the APK's assets (the same game/plugins/<id>/ layout) and android.rs points the host's file reader at the APK. Compile-checked for aarch64-linux-android; never run on a device.
  • Host services (blockloom-plugin-host/src/services.rs): a module's host.call reaches one HostServices hub, named area.verb: host.version, rng.* (hashes of {seed, index}, so a replay matches), storage.* and save.* (need project-storage), diag.*, jobs.*, and whatever a ServiceProvider chained on adds (the world's physics.* and nav.*, blockloom-runtime/src/plugin_services.rs, answered from the published sensor snapshot and the nav bake). The editor and the world each build one hub; the capability gate stays in native.rs.
  • Storage (storage.rs): two stores namespaced by plugin id, project blobs (.blockloom/plugin-data, read-only in a built game) and player saves (per project, written while a game plays). BlobStore is a flat key space with atomic multi-key commit; DiskStore writes through a temp file and a rename, MemoryStore serves tests. Content blobs live under blobs/<sha256> (put/get) and every write is held to StoreLimits (16 MiB a blob, 256 MiB and 4096 keys a plugin). KvStore<B: KvBackend> keeps a plugin's saves in any key-value backend (base64 values under a per-project prefix, a failed commit puts back what it changed); the web player's backend is localStorage (web::LocalKv). plugin-data-gc [dryRun=true] (storage::collect_blobs, commands::plugins::plugin_data_gc) removes content blobs nothing names: a blob:<sha256> string in a record, another stored key or a reachable blob, over the project's data and its saves; refused while a game runs.
  • Jobs and generation (jobs.rs, generation.rs): jobs.start runs job.<name> in slices under a per-frame budget (WorldPlugins::run_jobs), highest priority first. A slice answers {progress, state}, {done, result} or {error}; state comes back next slice, cancel calls the op once with cancel: true, and a job's event is raised when it ends. A package's nodes are typed graph nodes (blockloom-plugin-api/src/ generation.rs, GraphDef::plan checks types, one source per input and cycles); the graph.evaluate job evaluates one node per slice through a tile cache (GraphCache) with per-node margins, so an edit recomputes only what it feeds.
  • Diagnostics (diagnostics.rs): diag.count|gauge|time|marker plus the host's own call counts, timings, errors and faults per plugin. plugins::report sends them from the world about every fifth status report (RuntimeMessage::PluginDiagnostics, and plugin/* render metrics for the profiler); StateDto.plugin_diagnostics, plugin-diagnostics in the shell and the Plugin Manager's rows read them.
  • Editor surfaces (blockloom-plugin-api/src/surfaces.rs): a package's menus, shortcuts and overlays. A menu item or shortcut runs one of the package's commands with fixed arguments (so it is that command's validated, undoable action): the top bar's plugin menus and Main.qml's Instantiator of Shortcuts call plugin_call. An overlay is a scene-view toggle that asks the hosted preview module for overlay.<name> and draws the answered Shapes as gizmos (SceneView::plugin_overlays, PROTOCOL_VERSION 29); overlays only look. surfaces_json in commands/plugins.rs builds the snapshot. conflicts in a manifest, two packages providing one service and hook orders that cannot be met are ActivePlugins::conflicts(); a blocking one stops Play and Build and is listed by plugin-check and the manager.
  • Rendering service (blockloom-plugin-api/src/rendering.rs): an instances effect draws many copies of one of the plugin's meshes (position, yaw, scale each) as entities sharing the mesh's buffers and material, which Bevy batches (plugin_meshes.rs, PluginInstance); a mesh may be a convex_hull or aabb collider as well as a trimesh. A package's shaders are .wesl files registered as blockloom::plugin_<id>_<name> (rendering::module_name): core's shader_lib keeps a global extra-module registry (set_extra, so the editor's surface check can link them and a plugin cannot replace a built-in module) and the runtime's passes::sync_plugin_shaders adds each as a Shader asset when the loadout's shaders change.
  • GPU compute (blockloom-plugin-api/src/compute.rs, the blockloom-plugin-gpu crate, blockloom-runtime/src/plugin_compute.rs; ADR 0001's twenty-third batch): a plugin never holds the device. A package's kernels (.wgsl, an entry, a workgroup size, named group-0 bindings read|read_write|uniform) need the gpu-compute capability, and check_kernel (naga, no optional capabilities) refuses a kernel whose bindings differ from its schema or whose loops aren't fors over a constant-bounded counter (total iterations within MAX_STATIC_COST), at seal, install and load. A module answers gpu_buffer, gpu_write, gpu_dispatch, gpu_read and gpu_free effects (Effect::gpu_command -> GpuCommand; no capability is an error), a read comes back as the module's gpu.result op. ComputeEngine (feature engine, wgpu) owns the buffers and queues per plugin; each frame it runs commands in order in one encoder under FRAME_INVOCATIONS, round-robin, writes through a staging copy so they stay ordered, and reads back through mapped staging buffers. The runtime's ComputeLink carries commands to the render world (run in RenderSystems::Cleanup, on Bevy's device) and reports back; Loadout::kernels carries the kernels, PluginHost::kernels_serial says when they changed, and a run's end clears buffers. The SDK's gpu module builds the effects. Tests: cargo test -p blockloom-plugin-gpu --features engine -- --include-ignored with VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json. Not offered: textures, push constants, indirect dispatch, WESL imports.
  • Reload and isolation: a changed loadout reaches the running world as WorldPlugins::reload: a portable module answers world.save, is replaced and gets world.restore; a native change needs the run restarted (ReloadReport::restart_needed). With BLOCKLOOM_PLUGIN_ISOLATION=process a native library is loaded by the blockloom-plugin-worker binary beside the editor (BLOCKLOOM_PLUGIN_WORKER names another; all hosts portable modules there too): isolated.rs speaks length-prefixed JSON frames over its stdin/stdout, host services stay in the host and cross the pipe, and a call past its wall-clock limit or a worker that dies stops the module, not the game. The worker ships beside the editor (the Linux install, the CI editor archives and the Windows installer carry it); a built game does not.
  • Authoring kit: plugin-new path=... id=... template=declarative|portable|native (blockloom-app/src/scaffold.rs) writes a starter package: a declarative one is sealed at once, a portable one is a crate (src/lib.rs is blockloom-plugin-sdk/templates/lib.rs, which the SDK's tests/scaffold.rs compiles and runs), schemas, build.sh (wasm build plus plugin-seal) and a CI workflow. A native one is the same crate plus a native library: its build.sh also builds this machine's cdylib and runs plugin-add-native path=... target=... library=... (scaffold::add_native: records the library under its target triple, makes the tier native, adds the native-execution capability and seals again), so each platform's library is one more run of it. The SDK's testing feature adds Harness and harness!(T) (harness!(T, [Capability::ProjectStorage])): the plugin runs in-process through the host's loader with memory stores. docs/plugin-api.md is the author guide. CI runs cargo test -p blockloom-plugin-sdk --features testing and the host with test-fixtures (the isolation test).
  • Not yet: a faster browser host (the page's own WebAssembly instead of wasmi in wasm; it needs a fuel substitute, since a browser instance has no budget to stop a runaway call), a headless browser proof and native editor modules. plugins/examples/com.example.health is the sealed proof package; plugins/examples/tally is the SDK one, with code, and plugins/examples/palette the importer/build-hook one.

Scripts

An actor's Script component names a real .rs file under the project's assets/scripts. It is a normal crate root: it links against one generated crate, blockloom, and names its entry points with blockloom::export!.

blockloom-core/src/script/ owns the build. abi.rs is the C boundary and is compiled twice - once into blockloom-core as the host's view, and once, as text, into the blockloom crate the script links against - so editing it changes both halves at once. ABI_VERSION must go up whenever it does, since a script built against an older one is still sitting in somebody's project folder; the runtime checks it before calling in. prelude.rs is the API over that boundary and is script-side only. Building is two rustc runs and no network - the blockloom rlib, then the script as a cdylib - cached in the project's .blockloom/build against the source, the toolchain and the ABI. There is no Cargo, so a script gets std and nothing else.

The editor compiles scripts on Play (commands::build_scripts) so rustc's errors land in the run log against the script's own line numbers; the runtime only ever loads what it finds. Scripts therefore need rustc on the machine that presses Play - a packaged install carries one in tools/rust beside the editor (script::bundled_rustc, or BLOCKLOOM_RUSTC), which wins over PATH; a linker is still the machine's own. The boundary is three calls, not one per verb, so adding something a script can do is a new constant in abi.rs rather than a new field in HostApi - which would break every script already built.

A script reads the world through the same frame snapshot the reporter blocks read (sense) and everything it does comes back as a vm::Effect, applied by the same systems. So a script and a canvas can drive one actor between them, and reading straight back after a write gives the old value, exactly as it does in the block editor.

A script has three entry points, any of which export! fills in empty: start, tick and event. event hears what the hat blocks start on: Engine::fire queues each event while scripts run (script_events), and step_scripts hands them over before tick as a ScriptEvent, a kind (abi::EVENT_*), four numbers, and words read back through TEXT_EVENT, since a web host can't hand a string into the script's memory. Messages, keys, actions, touches and interface events reach every script; clicks, collisions, particles and animation events only the actor they name.

A panic inside a script is caught by export! and logged rather than being allowed to cross the C boundary, which would abort the whole game window.

Script sandbox and guest languages

Besides the native cdylib, a script can run as a core wasm module under wasmi (blockloom-runtime/src/script_wasm.rs, the shape web builds already ship): only the three blockloom.read_number/read_text/act imports (WASI and anything else is refused at load), a 64 MiB ceiling, fuel per call, and a trap or empty tank stops that script with an error effect, not the run. Play builds the wasm beside the native library when the machine has the target's std; ScriptBackend (engine.rs) loads native first and falls back to wasm (BLOCKLOOM_SCRIPT_BACKEND=wasm flips it); wasm32 and Android only have the one browser/native variant. The frozen blockloom:script@0.2.0 WIT world is blockloom-core/src/script/wit.rs, and drift tests refuse an ABI verb with no mapping (a wit-parser test also resolves the text). Guests live in blockloom-script-guest: the Rust crate (src/, templates/minimal.rs), a freestanding C binding (c/blockloom.h, templates/minimal.c, built with clang --target=wasm32 -nostdlib; its verb numbers are held to abi.rs by c_header_covers_every_abi_verb) and a Python component (templates/minimal.py, componentize-py), a JavaScript component (templates/minimal.mjs, jco; TypeScript compiles to it first) and a Go one (templates/minimal.go, TinyGo wasm-unknown + wasm-tools), a C# one (csharp/, Mono on wasi-wasm, templates/minimal.cs) and a Kotlin one (kotlin/, Kotlin/Wasm WASI with a hand-written canonical-ABI binding, templates/minimal.kt; the host engine enables WasmGC and exceptions for it). Each has a build.sh. Runtime tests build the Rust and C templates and hold their effects to the WAT fixture's.

Component-model guests (Python, TypeScript, Go, ...) emit wasm components, which wasmi cannot run, so desktop builds host them under wasmtime (script_component.rs, cargo feature components, default on, absent on wasm32 and Android). The guest imports the typed interfaces and exports entry; each import is a thin wrapper over the same number_for/text_for/ act_for_asked the other backends use. Sandbox: 256 MiB, fuel per call, empty WASI context. A script path ending .wasm is prebuilt: Play does not run rustc on it and ScriptBackend loads a component under wasmtime or a core module under wasmi. Compiled components are cached per process and under .blockloom/build/components (first load of CPython or QuickJS is seconds), and wasi:http is defined as traps. wasmtime is why web and Android guests stay Rust and C only.

Game view

Backend::start_embedded takes an EmbeddedRuntime host, and RuntimeHandle then starts the world on a blockloom-world thread instead of spawning a child - same messages, over channels. bridge::attach routes the world's send into that channel. A panic in the world is caught and reported as Fatal, ending the run rather than the editor; a native crash (a script library, a GPU fault) still takes the editor down.

Bevy runs headless there: no winit, one update per frame the view presents (embed::paced waits on FrameExchange::presented, which the view calls on every swap and keeps asking for while a world runs; 50 ms at most, so a hidden view still hears the editor), and synchronous pipeline compilation, since async compile tasks outliving the device crash NVIDIA at exit. Cameras render at the view's real pixel size (FrameExchange::resize, debounced by the view, since each size is a new ring; a fixed resolution picked in the Game tab is sent as is at scale 1), with UiScale and the 2D projection following the view's pixel density, into a ring of three Vulkan images exported as dma-bufs (embed.rs, raw ash under wgpu). FrameExchange hands slots between the world and the view: the world never draws into the one being shown or waiting to be. The C++ GameView item imports the ring through EGL. This is why Qt is forced onto its OpenGL renderer, and onto EGL on X11.

The ring's layout is negotiated. The view offers (FrameExchange::accept) the tiled DRM format modifiers its EGL samples as a plain texture; the world turns on VK_EXT_image_drm_format_modifier through Bevy's raw_vulkan_init, allocates the ring in one of those, and has the cameras draw straight into it (aim_cameras swaps the claimed slot in as their output attachment) - no copy on either side. With no common modifier, or once the view refuses one it couldn't import, it falls back to a linear system-memory ring the frame is copied into, which NVIDIA only samples as an external texture, so the view copies it again through a small shader into a plain texture.

Status (positions, variables) and the run log reach QML through their own statusJson and logJson, not the whole state snapshot - re-evaluating every binding on each status or say stuttered the view. RunLog.qml appends by log.total rather than rebuilding its list.

Input is PreviewInput, as it is for the MJPEG preview: keys, buttons, position, text, scroll, touch, plus focus (which, once sent, decides whether the game is focused, and releases held keys and fingers when lost) and mouse_delta. Keys travel by physical position: the view turns Qt's nativeScanCode into a KeyCode name through blockloom_protocol::keys, falling back to Qt's key name where there is no scan code (macOS). Scroll and touch become Bevy's own MouseWheel/TouchInput messages in preview::drain_preview_inputs. A windowless world answers lock mouse with RuntimeMessage::PointerLock; the view then locks the pointer while it has the keyboard - Wayland pointer constraints and relative pointer (the generated glue is vendored in blockloom-qt/src/wayland/), cursor warping on X11 - and forwards raw motion. Escape always releases it and a click takes it back.

HDR frames can't go through the 8-bit ring or Qt's 8-bit window. On Wayland the view makes a subsurface under its window (makePlane in game_view.cpp, input passing straight through, sized by wp_viewporter) and offers it through FrameExchange::offer_hdr_surface; the world makes a swapchain on it (embed::HdrPlane), which is what DisplayOffers then reports. While the frame resolves HDR, the cameras' view takes the swapchain's format and aim_plane points them at its texture instead of a ring slot, and hdr_live tells the view to draw a zero-alpha texture Qt thinks is opaque, which shows through to the plane while overlays still draw on top. That is why the window has an alpha buffer on Wayland. BLOCKLOOM_HDR_VIEW=0 turns the plane off.

BLOCKLOOM_RUNTIME=process forces the child process and MJPEG preview on Linux and Windows too. macOS has no embedded world yet.

Scene view

While nothing runs, the Game view is a scene view over the same world: loaded, never started, and seen through an editor camera instead of the game's. A run ends the same way whether the Stop button or a stop all ended it (world::end_run): the world is rebuilt from the document, so the scene view never keeps where the run left things. commands::open_world brings a world up for it when the tab is shown (embedded, or with the preview on), so editing never waits for Play. blockloom-runtime/src/edit.rs is all of it: SceneEditor holds the camera (a flying one in 3D, pan and zoom in 2D), the selection and any drag.

Input is the same forwarded PreviewInput a game reads, but edit::interact takes it in order ahead of the game's systems, so a press and its release in one frame still make a click. A click picks the actor under the pointer (nearest along the ray in 3D, topmost in 2D) and reports Picked; a drag on it or on the gizmo's handles moves the entity's pose live, and the release reports Placed. commands::place_from_view writes that into the document as one undo step, which reloads the world like any other edit. A child placed in its parent's frame reports its new offset too, since that, not Place, is what the world builds it from, and such children follow a dragged parent live.

The tool, snapping and steps are blockloom_protocol::SceneView, an editor preference kept in QML Settings and re-sent to every world that comes up, as is the selection (EditorMessage::Select). Place has a uniform scale (the size blocks read and write) and a per-axis stretch in the actor's own frame; the scale tool's axis handles move the stretch and its centre handle the size. Handles are gizmo lines in their own HandleGizmos group, drawn over the world. In 3D the editor holds the view's pointer lock while the right button is down, so looking around reads raw motion.

Instancing and batching

blockloom-runtime/src/batching.rs, 3D only. A plain surface (no graph shader, no box projection, not a tilemap) draws through InstancedMaterial: the shared material is keyed by surface_key, the surface minus color and UV transform, and those two ride per actor in one storage buffer indexed by its MeshTag (InstanceTable, slot 0 the identity). So set color writes a slot rather than making a material, and same-mesh actors stay one draw.

batch_meshes (PostUpdate) merges what instancing can't group: actors with no body or a fixed one that hold still for settle_frames merge per streaming cell and surface, on a CellTasks background task (members draw themselves until it lands), and one that moves after merging draws itself for the rest of the run; small movers merge per surface every frame. A batched actor keeps its entity and only loses its own draw, through RenderLayers::none(). Merged meshes bake tint and UVs into vertex colors and UVs. BatchPolicy holds every threshold. Counts reach the profiler as batching/* render metrics.

LOD and occlusion

blockloom-runtime/src/culling.rs, 3D only. LodGroup is the one LOD selector: levels of a screen-size threshold (bounding diameter over viewport height) plus an optional mesh, picked with a hysteresis band in select_lod and announced by LodChanged; below the last level the entity leaves the main view. Content hands it levels (RenderCache::lod does spheres and capsules) rather than selecting its own, and batching leaves LOD'd actors instanced.

cull_views runs between Bevy's frustum pass and MarkNewlyHiddenEntitiesInvisible, removing LOD-culled and occluded meshes from the world camera's VisibleEntities only, so shadows are untouched. Occlusion is a software Hi-Z: the camera-facing faces of solid, opaque Occluder boxes (cuboid and plane actors) are rasterized, eroded a texel and reduced to a max-depth pyramid. Bevy's GPU OcclusionCulling and GPU frustum culling (NoCpuCulling) are camera toggles in OcclusionPolicy. Counts reach the profiler as culling/*.

Quality and scaling

blockloom-core/src/quality.rs owns saved settings, preset budgets and frame-time feedback. blockloom-runtime/src/quality.rs applies run-only overrides, LOD and render-target scaling, and publishes quality/* and budget/* profiler rows. Geometry pressure drops density/distance without reducing pixel rate; frame-time pressure lowers resolution to its floor first. Draw counts estimate mesh and material groups in the active world camera's visible list, excluding shadow/post passes and 2D sprites. Rebuilds clear the previous world's sampled costs. Terrain, vegetation, VFX and debris have independent sustained auto-drop multipliers for LOD distance, grass density and transient pool capacity. They reach a local floor before geometry asks for a shared preset drop. These run-only reductions reset on setting changes or rebuilds. The profiler reports terrain and vegetation under budget/<system>/distance_scale, VFX and debris under budget/<system>/density_scale. Fractured child meshes count toward debris. Water has its own sustained tessellation throttle, reported as budget/water/tessellation_scale. Preset density and the local multiplier thin lake/river grids and ocean rings while keeping their full extent. Water floors count toward water costs and keep their meshes when only tessellation changes. Props have a local LOD-distance throttle too (budget/props/distance_scale), applied to every prop level. Spheres and capsules swap to coarser meshes and then leave the view far out; cuboids and planes carry a cull-only level, so visible ones still merge into batches while culled ones stay out of merges entirely. Model placeholders carry a cull-only level: model::sync_model_lod keeps visible meshes intact and hides the ModelChild subtree when culled, shedding the scene's draws. If props stay over budget at the local floor, shared preset feedback takes over. Draw groups serving more than one visible mesh count as quality/instanced_draws, with the absorbed meshes in quality/batched_instances. The render world also counts what batching actually issued (indirect.rs): the world cameras' binned opaque, mask, deferred and transparent phases after batching, as indirect/api_draws, commands, instances, multidraw_sets and transparent_draws with a gpu flag saying the renderer has counted, plus per-system indirect/draws/<system> membership from the CPU's grouping, since bins don't know which system a mesh belongs to. Before the first render count the totals mirror that grouping instead. Texture streaming maps the preset onto surface samplers (a lod clamp plus an anisotropy cap, re-applied on quality change), laid over per-texture distance steps (Quality::distance_mip_steps, tracked from each variant's nearest material user in materials::track_texture_distances): far walls stream coarser while near ones stay sharp. Baked data textures (cloud noise volumes, grading LUTs, lens dirt) never register as variants. Grading LUTs share one depth-stacked 3D atlas (post::LutAtlas, paged in the finish shader; a new grid size rebuilds it), and authored cloud noise shares one stacked 3D atlas too (clouds::noise_atlas_texture over pipeline::pack_volume_atlas sizes): shape under detail in one image, paged in the march shader through noise_shape/noise_detail, so the march and the shadow pass bind once instead of once per volume. Baked pages render into scratch textures and copy into their slab once.

The main-world camera stays at native size for input. Extracted world-view targets scale before rendering and Bevy's spatial blit fills the output. Custom sub-viewports stay native. TAA is 3D only. A DLSS ask runs Bevy's dlss path in a build made with --features dlss (just player-dlss): just dlss-sdk fetches the pinned SDK once per clone (sparse, about 330MB under gitignored third-party/dlss; cloning it accepts NVIDIA's license), the justfile exports DLSS_SDK, and a Vulkan SDK plus clang complete the build (VULKAN_SDK, default /usr on Linux). The Dlss component carries the effective perf mode on perspective HDR world cameras - plain super resolution, or the ray-reconstruction variant while Hybrid Solari lights the view - and drives its own render resolution while the spatial scale_views stands down. Manual modes step down with the dynamic-resolution signal; Auto (the default) leaves the ratio to the SDK. Otherwise - no SDK build, no RTX, no Vulkan, missing DLLs, 2D, or web - it rides TAA plus spatial in 3D and spatial in 2D and on web. The probe reads DlssSuperResolutionSupported where the feature exists and the adapter elsewhere, reports through the same dlss_available flag the reporters read, and says why once in the run log when a run asks for it. The redistributable (nvngx_dlss / libnvidia-ngx-dlss plus the dlssd twin and a license blurb) stages beside the player payload and ships beside each built game; without it the game falls back to TAA. With scene scaling, a separate unlit UI camera composites at native resolution after the scene blit, using premultiplied alpha and no world render layers. It follows the world output and owns IsDefaultUiCamera while active. Under HDR the world's tone curve stays on the scaled scene while the encode moves onto the UI camera, so the HUD is encoded at paper white at full resolution instead of blending in linear light. Script rendering overrides end on a world rebuild.

ssr.rs traces only specular light into the shared half-size scratch target, then adds it to the scene with the depth-guided upsampler. Bevy still supplies SSR uniforms and view layouts; pbr_patch makes its shader emit just the light under BLOCKLOOM_HALF_SSR. Unpatched shaders, compiling pipelines and custom viewports keep Bevy's full-size pass. The trace timing is ssr_half.

CellTasks bounds concurrent work per payload type and admits nearest cells first. Global HDRI/noise jobs use GLOBAL_CELL, remain part of warmup accounting across cell resets, and cancel when their content key changes. HDRI quality caps discard highest-resolution baked mips before GPU upload, retaining BC6H where supported. The preset caps authored volumetric quality too: fog grids step down to the preset's grid and cloud march steps step down to its CloudQuality (clouds::capped_quality), so a Low world never marches Ultra steps.

GPU measurement

blockloom-runtime/src/gpu.rs. Bevy's RenderDiagnosticsPlugin times each pass; gpu.rs adds one timestamp pair around the whole frame (an encoder pushed in RenderGraphSystems::Begin and one before Submit, read back a few frames later) as gpu/frame. Every MEMORY_EVERY frames it sorts the wgpu allocator's report into Kinds by allocation label (Kind::of), so a new render target needs a label that sorts, not code. Without a report (Metal) it sizes the views' own textures instead. All of it reaches the profiler as gpu/* and memory/*.

Loading and streaming

blockloom-runtime/src/streaming.rs. StreamingCells is the one cell system: XZ cells around the world camera with a hysteresis band, admitted nearest first under a per-frame budget and announced as CellEntered/CellLeft messages (a rebuild sends every cell as left). Content streams by being a payload: it reacts to those messages and does its work through CellTasks, which runs on the async compute pool and holds the cell as loading until the task lands. Terrain chunks and the like belong there, not in a second system.

A look whose files are still loading gets Loading: a flat Placeholder child in 3D (Bevy draws nothing for a material whose texture isn't in yet), and a FadeIn on the sprite in 2D once the image lands. A model's own box is its placeholder already.

Every rebuild opens a Warmup window: everything with bounds is drawn unculled (NoFrustumCulling, and cull_views stands down), so every pipeline compiles now rather than when a thing first turns up on screen. It closes after a few quiet frames with no loads, no loading cells and no pipelines in the render world's backlog (PipelineBacklog), or at its timeout. Start doesn't press the green flag itself in a rendering world: engine.starting holds it (reported to the editor as running) and warm_up calls world::begin_run when the window closes, so a built player's first frame is warm too. A bare test world (engine.prewarm false) starts on the spot. At build time build::check_shaders compiles every .wesl surface file the project draws with, and a broken one fails the build. Counts reach the profiler as streaming/*.

Environment

blockloom-runtime/src/environment.rs. Environment is the one resource the background, sun, ambient, AO, exposure and post come from: each frame blend_environment starts from the project's World settings, lays EnvironmentVolumes over them by weight (numbers and colors lerp, switches flip at half weight), and resolves exposure through ExposureClaims (director beats auto-exposure beats the manual EV). apply_environment writes it onto the world camera, the sun and ClearColor when it changes or a rebuild spawns new ones, and it is extracted to the render world. Passes read Environment, never project.world.lighting/post; the rebuild only spawns a bare camera and sun.

Environment volumes

blockloom-core/src/volume.rs is the model: a Volume component (VolumeSpec) makes an actor a box, a sphere or a global layer with a priority, a blend distance (outside the shape, in world units) and a weight. Every property carries an HDRP-style override checkbox (Override { on, value }, the value kept while unchecked), so a volume only changes what it ticks. coverage is the shape maths, flat in 2D; blend_order sorts lowest priority first so the highest wins.

blockloom-runtime/src/volumes.rs is the rest. gather_volumes weighs every carried volume at the world camera (the editor camera in the scene view), fills EnvironmentVolumes for blend_environment, and keeps the list in VolumeBlend, which the atmosphere sample turns into active volumes and the status reports as Status.volumes. enable volume and set weight of volume (and a script's enable_volume/set_volume_weight) name a volume the way set my parent to names a parent and last for the run (engine.volume_enabled/volume_weight). The debug views are SceneView.volumes: bounds with their blend feather, a heat map, and freeze, which holds the blend and sends each property's lerp as Status.volume_trace. The heat map is volume_heat.rs, a pass after tonemapping that puts each pixel back in the world (the depth prepass in 3D, the screen plane in 2D) and runs the same coverage maths in shaders/volume_heat.wesl over at most MAX_VOLUMES; change the two together. A selected volume shows grips in the scene view (edit.rs): a box face moves with the opposite face held, a sphere's edge sets the radius, and the outer ring the blend distance. The drag updates the world's copy of the spec live, and lands as Placed with volume set, one undo step. A property the Phase 5 systems add is one field on VolumeOverrides, EnvironmentOverride and Environment, plus its row in the inspector's volumeProperties.

HDR frame and lights

Every world camera carries Hdr (environment::apply_environment), bloom or not: the scene renders linear FP16 and only the end of the chain makes display values, so lights, sky and emissives can pass 1.0. A build made with HDR off (GamePack.hdr, the Build dialog's switch) renders 8-bit instead. Exposure stays the one EV on Environment; set exposure to (and a script's set_exposure) takes ExposureClaims::director for the rest of the run, and the atmosphere slot's exposure reading reports the resolved value.

World.display is the project's output: SDR, HDR10 (PQ) or scRGB, peak brightness and paper white in nits. hdr::HdrFrame resolves it, with set HDR output/set peak brightness laid over it for the run, against what the window's display offers (DisplayOffers), and is extracted so both worlds switch on the same frame. Only the windowed player can leave SDR: display.rs makes the window's surface itself in the render world, keeps it when the display offers an HDR color space, and hands Bevy's views its texture each frame; otherwise Bevy keeps the window. On an HDR frame the tonemapper stands aside for HdrTone* (a curve to the display's headroom) and HdrEncode* (scRGB or PQ, after the UI so the HUD sits at paper white). An HDR swapchain also gets HDR10 static metadata (display::send_metadata: VK_EXT_hdr_metadata, turned on by add_vulkan_extensions, or DXGI), resent whenever the swapchain is remade. The Game view's ring is 8-bit, so on Wayland an HDR frame bypasses it (see Game view).

hdr.rs also holds the Game view's debug views, a FullscreenMaterial per dimension over the exposed image before tonemapping (shaders/hdr.wesl): false color, a clipping zebra past paper white (past the headroom when the project wants HDR), a histogram, a waveform, calibration patches and an HDR preview. The choice is SceneView::debug_view, an editor preference that applies while a game runs too. luminance.rs meters the world camera's exposed image with a compute pass and reads it back; the atmosphere sample turns it into the scene luminance reporter's nits on the fixed tick, beside is HDR display? and peak brightness. The meter, the post stack's HDR pass, the debug views and the tone curve run after everything Bevy's tonemapper runs after: hdr::ToneInputs, a schedule build pass, adds those edges, since passes that flip the main texture must be ordered or they submit out of the order they flipped in. capture.rs answers EditorMessage::CaptureExr: a second camera renders the same view untonemapped into FP16, read back and written as OpenEXR (capture_exr, the Game view's camera button).

The sky (below) is drawn and lights the world in the same FP16 frame. An HDR asset's exposure bias (set_exposure_bias) is applied wherever it is loaded. set my glow to gives an actor its own material with the emissive scaled (dim3::set_glow).

A Light component is a point or spot light in lumens with a range in metres, 3D only. lights::sync_lights reconciles each actor's light against engine.attached, the authored spec and engine.light_intensity (what set my light to wrote this run), and hangs it on a child entity, since batching hides a merged actor through an empty RenderLayers.

The GPU half is checked by the ignored tests in embed.rs (cargo test -p blockloom-runtime -- --ignored embed), which read pixels back from a real world: false color in both dimensions, a lamp that still lights the floor after batching, and an EXR capture that stays linear.

Post stack

World.post (scene::PostProcess) is the whole camera finish, both dimensions unless noted, in a fixed HDR-first order: motion blur, depth of field, chromatic aberration and vignette (Bevy's own, set in environment::apply_environment), then Blockloom's HDR pass (bloom, white balance, grading, the toe/shoulder ToneShape), the tonemapper (AgX and Khronos Neutral included), FXAA/SMAA and CAS sharpening, then Blockloom's LDR pass (LUT and film grain). Every property volumes can blend has a field on VolumeOverrides and EnvironmentOverride, and Environment.post holds the blended chain; the LUT and dirt files, bokeh, metering mode and AO intensity stay the project's.

post.rs is Blockloom's half. apply_post resolves each world camera's PostStack from the blended environment, loading the LUT through pipeline::load_volume into a 3D image and the lens dirt as an asset. post_hdr (in hdr::ToneInputs, after luminance::MeterPass) runs a five-level bloom chain at half resolution (Karis-weighted soft-threshold prefilter, 13-tap downsamples, tent upsamples scattering each level into the one above) and a composite that adds bloom times dirt, then grades. post_ldr (PostLdrPass, after the tonemapper and cas) works on display values; the LUT is skipped under HDR output. shaders/post.wesl holds every entry point with unique binding numbers, and PostUniforms matches it field for field. Bevy's Bloom is never used. The debug views BloomMip (SceneView::bloom_mip), CircleOfConfusion and AmbientOcclusion are drawn by post_hdr and bypass the tonemapper.

Auto-exposure (3D) reads the meter's average, center-weighted or spot log-average (luminance.wesl), aims for middle grey plus compensation inside min_ev..max_ev and moves at speed_up/speed_down stops a second (AutoExposure::target/step); post::auto_expose writes that into ExposureClaims::auto, so the director track still wins. Depth of field focuses on the named actor's depth along the view (focus_depth_of_field), maps Hexagonal/Circular bokeh to Bevy's Bokeh/Gaussian, and a negative max_depth tells the pbr_patch edit of Bevy's dof.wesl to keep the near field sharp. AO intensity is a power on SSAO visibility, baked into Bevy's ssao.wesl by pbr_patch in twentieths (each patch re-applies only when its own key changes). SSR turns opaque surfaces deferred through ray_tracing::apply_ray_tracing, and it and SSAO turn MSAA off (Environment::wants_msaa_off). EXR captures copy only the bloom (PostStack::linear_only). The GPU half is the ignored embed tests (grading, every bloom level, grain, AO and blur size debug views).

Sky

World::sky (blockloom-core/src/sky.rs) is one of four kinds: Flat (the background color and flat ambient, the default), Physical (Rayleigh, Mie and ozone single scattering, a limb-darkened sun and a phased moon, a night tint ramped on the sun), Gradient (three stops that warm near a low sun) and Hdri (a panorama or strip, turned and tilted, tinted, optionally blurred). SunPlacement decides where the sun stands for every kind - the lighting's own direction, azimuth and elevation, or NOAA's solar position from latitude, longitude, day and hour - and Environment::from_world takes it from there, so volumes still override it. A physical sky's air then reddens and dims the light (Environment::through_air, the CPU half of the same model, kept as Sun::above_air for the sky to scatter). Old documents' lighting.sky path becomes an HDRI sky in Project::normalize.

blockloom-runtime/src/sky.rs renders it, 3D only. SkyParams (the WESL struct in core's shaders/sky.wesl, the blockloom::sky library module, field for field) is resolved each frame from the sky and the blended Environment. When it changes, shaders/sky_cube.wesl writes the sky's light, disks left out, into a 256 cube and a 32 cube scaled by the ambient dimmer; Bevy's GeneratedEnvironmentMapLight filters them on two helper entities (SkyProbe), and the world camera's EnvironmentMapLight takes reflections from one and diffuse from the other, or a black cube for whichever the sky's toggles leave out. The filter stops a few frames after the render world reports the cubes written (SkyRender::written) and the pipeline backlog is empty, so a still sky is filtered once. Cubes store nits over SKY_UNIT to stay inside FP16. The background is shaders/sky_background.wesl, drawn per pixel in Bevy's opaque pass through the skybox slot (SkyView supplies SkyboxPipelineId/ SkyboxBindGroup, never a Skybox): gradients per pixel with dither, the physical sky from its cube plus analytic disks, an HDRI sharp from its image or blurred from the filtered mips. Probe faces and EXR captures copy SkyView (probe faces without the disks). A build bakes an HDRI to a BC6H cube with its mip chain (build::bake_sky, seam fix applied) and drops the source. sky_exposure and ambient_dimmer are volume properties too.

Fog, space and lightning

World::fog (blockloom-core/src/fog.rs) is three media, 3D only: an analytic exponential height fog, a froxel volumetric fog and aerial haze. blockloom-runtime/src/fog.rs resolves them from the blended Environment into FogRender each frame (so volumes, set fog density to and the time of day reach them the way they reach the sun) and draws them in one pass after the main passes and before any post. shaders/fog_froxels.wesl injects each froxel's medium (height-falling density, FBM noise drifting with noise_wind, and up to MAX_LOCAL_FOG local fog volumes) and its in-scattered light: Bevy's directional lights read straight from its own GpuLights and cascade shadow maps (the sun and moon carry VolumetricLight while volumetric fog asks for them, which is what makes light shafts), plus up to MAX_FOG_LIGHTS unshadowed point and spot Lights whose volumetric switch is on. It blends with last frame's grid reprojected, and a second entry point integrates each column front to back. shaders/fog_composite.wesl then reads each pixel's distance from the depth prepass: haze on surfaces, height fog along every ray (the sky's too, so the horizon melts into it, glowing with the sky's horizon light from the diffuse cube), then one froxel fetch. The shared WESL is the blockloom::fog library module; fog::FogUniforms matches it field for field. Fog properties on Environment (fog_density, fog_colors, fog_height, volumetric_density, volumetric_albedo, haze) are volume properties too, and a Volume can also add local fog in its own shape (VolumeSpec::fog).

A light's beam (fog::Beam) is extra medium only that light scatters: density, its own g, a falloff curve over the range and near/far fades, mirrored by beam_fade in blockloom::fog. In the froxels it rides FogLight.beam, and a beam alone runs the pass. fog::pick_fog_lights keeps beams first, then the nearest, up to MAX_FOG_LIGHTS. Environment.beams multiplies every beam (a volume property too), and set fog density (Environment::set_fog_density) scales it and volumetric_density by the asked density over the project's own, so a clear day has no beams. blockloom-runtime/src/beams.rs is the geometry: a BeamMode::Auto beam becomes an additive fresnel-faded shaft cone (shaders/beam_shaft.wesl, spots only) while volumetric fog is off or Low, and Motes are GPU-placed billboards (shaders/beam_motes.wesl) in a beam or, as VolumetricFog::dust, in a box wrapped round the camera.

Stars, the Milky Way and aurora (Sky::stars, Sky::aurora) are drawn by the sky's background pass in the main view only, never in probe faces or the light cubes, from space::SpaceRender (blockloom::space). They sit in their own uniform rather than SkyParams because they move every frame, and a SkyParams change rewrites and refilters the cubes. So there are no stars over a flat sky. space.rs also hangs the moon's own DirectionalLight (MoonLight) where the physical sky puts it, dimmed by its phase, and applies set aurora to KP.

World::lightning (blockloom-core/src/lightning.rs) is both dimensions. StormDirector is plain arithmetic on the fixed tick from a seed, so one storm replays the same. blockloom-runtime/src/lightning.rs turns strikes (the director's and strike lightning at's) into a decaying PointLight above the ground (3D), a LightningFlash that pulses the environment's ambient and background and the sky pass, and thunder late by distance at the speed of sound: the project's sound, or thunder_wav, a rumble made in code so no asset is needed. The fog, the aurora and the flash fill their AtmosphereSources readings (fog density, aurora, lightning).

The GPU half is the ignored embed tests: height fog, volumetric glow, sunlit fog and a roof's shadow in it, a volume's local fog, aurora, stars and a lightning block.

Wind

World::wind (blockloom-core/src/wind.rs) is the one wind, both dimensions: a direction (degrees clockwise from north, -Z in 3D and up the screen in 2D), a speed at the reference height, gusts on seeded 1D gradient noise that also veer the direction, a log-law profile towards the ground (3D only), and a storm dial that scales the rest (StormScale). A Volume can carry a LocalWind zone (override, add or swirl round the actor's up axis, plus turbulence), blended in blend_order like the rest of a volume. CloudDrift is how the clouds ride it: the wind at their altitude times follow, their own drift, an erosion drift and a time-lapse; CloudOffsets integrates it. The public API speaks arrays because core's glam isn't Bevy's.

blockloom-runtime/src/wind.rs steps it on the fixed tick's own clock (step_wind, before sample_atmosphere), so a replay gusts the same and the atmosphere slot's wind speed, wind direction and storm are the wind at the camera that tick. Everything that moves with the air reads the resulting WindField: particles ease into field.at(position) scaled by their emitter's wind, the fog's noise scrolls by field.drift, and the cloud passes are meant to take field.clouds. set wind [dial] to and set cloud drift to (and a script's set_wind/set_cloud_drift) land in engine.wind for the run.

Water

A Water component (blockloom-core/src/water.rs, WaterSpec) makes an actor an ocean, a lake or a river: the actor's Place is the surface's centre at rest and its yaw turns the rectangle and a river's flow. An ocean has no edge and reaches OCEAN_REACH from the camera. The surface is 8-12 Gerstner waves (WaveSet::build, seeded, lengths falling from the authored wavelength, heading the project's wind or the spec's own), resolved each tick with the sea state and chop laid on (WaveSet::resolve, horizontal amplitudes shrunk before a crest folds). The sea state follows the wind at the body through a fetch-limited JONSWAP fit (sea_target, eased by settle_sea). Phillips-spectrum detail waves only bend the shading normal. WaterBody is one body resolved for a tick, and sample inverts the Gerstner displacement to answer height, normal, velocity and pinch (jacobian, where crest foam starts) over any point. shaders/water.wesl (blockloom::water) sums the same waves from the same numbers, so change the two together. A 2D body's waves run along x only (WaveSet::flattened). Past CALM_FROM wavelengths from the camera the drawn waves fade flat, and WaterBody::calm makes the CPU sample fade the same way, so buoyancy far out rides what is drawn. RippleField is a height field over the waves, stepped by the wave equation (RippleSim): sample adds its height, slope and rate, and ripple in water.wesl reads the same grid from a texture.

blockloom-runtime/src/water/ is the rest. sample_water runs at the head of each fixed tick after sample_atmosphere, fills WaterState (what the surfaces draw) and WaterSample, publishes the latter with sense::publish_water, and publish_sensors copies it into each frame's snapshot, so water height at, is _ underwater? and a script's water_at/is_underwater read what buoyancy read. set water level/chop/ foam to (and a script's set_water) lands in engine.water (WaterOverrides): on the water actor that ran it, or every body from anyone else. The swell follows the live wind: Swell eases a heading towards it and, once the waves are TURN_THRESHOLD off, builds the new heading's set and fades between the two over waves.turn seconds (blend_waves), since rotating a wave in place slides its phase. Each body with ripples.enabled keeps a RippleSim covering it whole, or following the camera when it is bigger than ripples.extent, and hands a snapshot to its WaterBody each tick. A Buoyancy component (BuoyancySpec) makes a dynamic body float: float_bodies_2d/3d push up at 1, 4 or 8 sample points by what each displaces (buoyant_force, so density 0.5 rests half under), drag each towards the water's own velocity and damp spin, all through rapier's ExternalImpulse with the mass read back through ReadMassProperties. The same system splashes any rigid body that crosses the surface faster than the body's splash.min_speed: droplets (fx::spawn_splash), a dip in the ripple field and the body's splash sound, and a floating body's points that cut the surface stir the field as it moves (its wake). The surfaces read the field as an Rg32Float texture of height and last tick's height, lerped between ticks.

3D (surface.rs): each body is its own entity (WaterSurface, left out of ray-traced proxies), a grid over its rectangle or rings round the camera for an ocean, drawn with WaterMaterial (shaders/water_surface.wesl). Its base StandardMaterial has transmission, which puts it in Bevy's transmissive pass and so after the depth prepass; sync_surfaces gives the world camera ScreenSpaceTransmission while any water exists. The fragment refracts the opaque frame (view_transmission_texture), absorbs it by the distance to the bed (Beer), lays caustics on the bed, foams along the shore and on pinched crests, reflects by screen-space march, then the body's probe (ProbeRequest::water, following the camera over an ocean through ProbeService::relocate), then the background color, and lets Bevy's lighting add the lit water color and the sun's GGX glint. Planar reflections instead come from a mirror camera (mirror.rs): the world camera mirrored under the surface and turned upside down, which keeps triangle winding, so the surface samples it with v flipped. Its MirrorProjection is an oblique reverse-z perspective whose near plane is the water (oblique), so nothing under the surface blocks it; roughness blurs it with a disc of taps. Each body also has an opaque floor at its depth, which is what the depth prepass sees where the water is. While water exists the world camera's main depth is bindable, and the fog pass reads that instead of the prepass, so fog is measured to the surface rather than the floor under it. under.rs is a pass after the fog that absorbs every ray over its underwater part and lays caustics while the camera is under a surface. 2D (flat.rs): a strip from the surface to the depth, its top row riding the waves (water_2d.wesl). The GPU half is the ignored embed tests a_lake_tints_the_floor_under_it and a_planar_mirror_reflects_what_stands_over_the_water; on lavapipe the water's pipelines take a few hundred frames to compile.

Particles and VFX

An Emitter component (ParticleSpec, blockloom-core/src/material.rs) carries the whole VFX graph (blockloom-core/src/vfx.rs): a spawn SpawnShape and LaunchDirection, rate plus timed Bursts on the emitter's own clock (vfx::due), an ordered UpdateModule stack, ParticleRender (blend, facing, flipbook, Curves and a Gradient over life, baked to LUT entries for the GPU) and a RibbonSpec. Particle is one 64-byte slot, byte for byte Particle in shaders/vfx.wesl (the blockloom::vfx library module), whose hash and noise match core's, so the two sims spray the same way.

Two sims fill one buffer. vfx::Pool is the CPU one: 2D, SimMode::Cpu, VfxSettings::cpu_only, devices without compute and RibbonSource::Actor (slot 0 pinned to the actor) run on it, up to CPU_MAX, colliding with actors' sensor shapes through physics_query::segment_contact. Everything else in 3D runs blockloom-runtime/src/shaders/vfx_sim.wesl: begin resets one EmitterState, simulate steps every slot, spawning claims slots through an atomic budget, and Collide tries the frame's actor shapes first (collider_shapes, the nearest GPU_COLLIDERS bodies' sensed boxes and balls, the emitter's own left out), then the depth prepass (reverse Z), so bodies off screen or hidden still stop particles. SimParams in vfx.rs matches the WGSL field for field; change the two together, and ParticleLook with vfx_particles.wesl.

blockloom-runtime/src/vfx.rs is the main-world half: step_emitters (Update, after interpolate_poses) keeps a Draws entry per emitter (the buffers, one or two slot meshes, the ParticleMaterials and the sim), decides what spawns within the project's budget, steps the pool or pushes a GpuStep, and fires Event::Particles once a frame per kind while a run is live. A mesh surface spawns on the actor's own mesh and every mesh under it (a model's parts), gathered in the actor's frame. vfx/gpu.rs is the render-world half: pool bytes land through VfxFrame uploads, and the compute pass runs once a frame in Core3d between the prepass and the main pass. GPU counts come back through a Readback of the state buffer, tagged with a step sequence so each set is used once and tied to its pool's reader so resizing cannot reuse old counts; each event kind carries where its last one happened, and ParticleSenses hands counts and positions to publish_sensors for the particle reporters and a script's particles(). Overdraw is counted, not estimated: every particle fragment adds to one OverdrawMeter counter, zeroed each frame and read back as fragments over the view's pixels. vfx/render.rs is the draw: the mesh's positions only name a slot, a corner and a ribbon segment, and vfx_particles.wesl (3D) or vfx_particles_2d.wesl reads the particle. A lit 3D particle receives shadows and, with translucency, adds the light from behind it. While nothing runs, the actor the scene view has selected plays its emitter on a loop of duration. Draws go on a rebuild (fx::despawn_fx). fx.rs is now only splash droplets and trails. Counts reach the profiler as vfx/*. The emitter is a module stack in the order VFX Graph runs its contexts (spawn, initialize, update, output), not a node graph. The GPU half is the ignored embed tests: GPU and CPU particles draw, a GPU spark lands on a hidden body, ribbons draw without heads, and the overdraw meter counts.

Decals

blockloom-core/src/decals.rs owns transient marks: a 256-entry LRU pool, lifetimes and radius fades, plus four matching atlas pages for blood, footprints and fresh scorch. SpawnDecal takes a position, outward normal, size, lifetime and final fade; FadeDecals fades centres within a sphere. Both run through the VM and compiled logic. Marks are never saved.

blockloom-runtime/src/decals.rs projects the atlas with Bevy's clustered decals. pbr_patch appends shaders/decals.wesl to Bevy's decal module: the tag packs atlas cell and opacity, and the shader fades by surface angle and projector depth before blending albedo, normal, roughness and emission. Standard, instanced, box-projected and terrain PBR surfaces receive them. decals_deferred.rs applies the same shader to the G-buffer between prepass and lighting, using a scratch target to avoid reading an attachment being written. This also feeds Solari and the traced game lighting. The pool touches visible projectors, pauses with the run and clears before rebuilds. Profiler counts are decals/active, decals/budget and decals/stolen. This path needs native texture binding arrays and reports an error once on unsupported renderers, including WebGPU. Unlit graph surfaces and the reference path tracer do not receive decals.

Lighting rig

Light (LightSpec) is a point, spot, rect or disk light. Rect and disk are Bevy RectLights with LTC speculars. intensity is lumens or, with unit: Candela, the brightest direction's candela (x 4 pi to lumens). A cookie (tiled over a spot's beam, or on each face of a point light) and an IES profile are baked on the CPU into one R8 mask in lights.rs (spot_mask/point_mask, which invert Bevy's own texture lookups) and handed over as SpotLightTexture/PointLightTexture. Masks are cached in LightMasks by file, tiling and cone.

pbr_patch.rs edits Bevy's own PBR shaders as they load, for what Bevy has no switch for. A disk is sent with both extents negative (the side of its equal-area square) and integrates as a 12-gon of the same area. A shadowed area light gets a ShadowTwin child, a black point light at exactly its position, whose cube shadow the rect loop looks up in the cluster; black point lights are skipped in the point loop. The sun's shadows fade over the last ShadowSettings::fade of the distance (a shader constant, so changing it recompiles). Each patch is exact text against the pinned Bevy; one that no longer matches reports an error and leaves Bevy's shader alone. Per-light contact shadows, PCSS and biases ride the same spec; turn my light's shadows lands in engine.light_shadows, which wanted lays over the authored spec, and casts shadows? reads ActorSense::casts_shadows.

Lighting::shadows (ShadowSettings) is the sun and camera half, applied by shadows.rs: cascades (count, first split, blend, distance - which set shadow distance overrides as engine.shadow_distance), normal bias, a PCSS sun size, the fade, the camera's ShadowFilteringMethod and ContactShadows (16 steps). Lighting::sun_cookie is a tiled DirectionalLightTexture whose tile size is the sun's scale, which is safe because cascades read only its rotation. The depth bias and everything volumes blend stay in environment.rs.

A Probe component (blockloom-core/src/probe.rs) is a reflection probe (a box-projected cubemap, ParallaxCorrection::Auto) or an irradiance volume (a brick grid of ambient cubes in Bevy's (Rx, 2Ry, 3Rz) atlas). Its box turns with the actor but isn't scaled by it. light_probes.rs bakes through the capture service: a reflection capture is rotated with the probe, since Bevy samples a probe's cube in its own frame, while bricks are world-aligned, since the irradiance lookup uses the world normal. Captures are divided back out of the camera's exposure into radiance. BakeProbes (the inspector's Bake, bake-probes) writes .blockloom/probes/<actor>.{dds,irr,json}; the json holds probe::stamp, a hash of the probe and every lit component around it, so probe-status and the scene view know a bake is stale, and an auto-bake probe rebakes itself after the next rebuild once warm-up closes. A bake leaves out its own actor (ProbeRequest::hide, applied by probes::hide_from_captures), so a probe actor (ProbeOwner) is never batched. Volumes scale probe light through reflections (reflection probes and the sky's light) and indirect (irradiance volumes); probes blend with each other by their own falloff. capture probes does the same capture mid-run and keeps it in memory. Builds copy the bakes. A reflection bake loads as BC6H where the GPU samples BC, and is decoded to FP16 elsewhere, as the sky is. pipeline::bc6h encodes all 14 modes, and its decoder matches bcdec bit for bit. The GPU half is the ignored embed tests (rect and disk lights, area light shadows, cookie, both probe kinds, a bake leaving out its own actor). Those tests start their worlds one at a time: concurrent Vulkan instance creation crashes in the loader.

Ray-traced lighting

blockloom-runtime/src/ray_tracing.rs, 3D only, behind the runtime's default ray_tracing cargo feature (Bevy Solari, pinned with Bevy). Lighting::ray_tracing (RayTracingSettings) turns it on; turn ray tracing, set GI bounces to and set GI samples to (and a script's set_ray_tracing, set_gi_bounces and set_gi_samples) override it for the run as engine.ray_tracing/gi_bounces/gi_samples. RayTracingState probes the device once for SolariPlugins::required_wgpu_features; without them the raster rig carries on and the editor hears why once. The atmosphere sample copies active/available on the fixed tick, so is ray tracing on? and ray tracing available? agree between the VM and compiled logic.

RayTracingSettings::mode picks the realtime tracer. Hybrid puts SolariLighting on the world camera (ReSTIR direct light plus a world cache for GI). PathTraced puts traced::TracedPaths there instead: fresh paths from every G-buffer pixel each frame (shaders/traced_paths.wesl, paths per pixel, light sampling MIS'd against the BRDF), with Bevy's deferred lighting skipped. Either way the camera also gets Msaa::Off, Hdr and a storage-capable main texture, and sun shadow maps turn off. Tracing lights the G-buffer, so while it is on DefaultOpaqueRendererMethod is deferred and every standard and instanced material is touched to re-prepare; off, both go back to forward.

Denoiser picks the cleanup: ReSTIR's reuse (reuses, Hybrid only) and traced::TracedDenoiser (filters), an SVGF-style filter (shaders/denoise.wesl) that runs after the opaque pass: demodulate by the G-buffer albedo, reproject along motion vectors, five a-trous passes steered by the variance, remodulate. Glossy surfaces keep a shorter history and a tighter blur. It only touches pixels with a G-buffer (forward surfaces and the background are left alone) and drops NaNs and clips fireflies first, since its moments are half floats.

Solari ignores the environment map, so solari_patch.rs edits its shaders as they load (the pbr_patch rules): a realtime bounce, a world cache GI ray past its reach and a reference path tracer bounce that escape all see the camera's EnvironmentMapLight. Under a flat sky TracedAmbient hangs a one-texel cube of the ambient there instead, and a sky that lights but doesn't reflect shows traced rays its diffuse cube. instanced_pbr.wesl is therefore the instanced material's deferred shader too. Box-projected and graph surfaces stay forward and keep the raster lights.

Solari only traces RaytracingMesh3d + StandardMaterial in one vertex layout, and only the sun and emissive meshes light. So sync_traced_scene (PostUpdate, after propagation) keeps a copy: one proxy per visible drawn mesh (no Mesh3d, traceable converting the mesh, a standard material standing in for instanced, box and graph surfaces) and one emissive stand-in per Light whose ray_traced is on (a sphere for a point, a disk down a spot's beam, the rect or disk itself), glowing with the light's power. A spot narrower than HOOD_WIDEST also gets a black flared hood (TracedHood), so its disk only lights the cone. Merged batches, placeholders and particles are left out.

The path tracer is SceneView::path_tracer, an editor preference: the world camera gets Pathtracer instead, which traces the same copy and starts over when it or the camera moves (drive_path_tracer); progress and availability reach the editor as RuntimeMessage::RayTracing and state.ray_tracing. An EXR capture copies the camera's tracing (trace_like) and, under the path tracer, waits for the sample or time budget. The GPU half is the ignored embed tests (traced vs. untraced lamps, switching mid-run, the path tracer and its EXR, sky and ambient on escaped rays, realtime path tracing, the denoiser's grain, a spot's cone). They run on lavapipe too, which has ray queries but no dma-bufs: BLOCKLOOM_TEST_OPAQUE_FD=1 VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json, and BLOCKLOOM_TEST_DUMP=<dir> saves the frames some of them read as PNGs.

Volumetric clouds

World.clouds (blockloom-core/src/clouds.rs) persists shape, altitude, lighting and quality, edited by set_clouds and Project Settings. The blended Environment.clouds carries volume coverage, density and type overrides, and set clouds [coverage/density/type] to (a script's set_clouds) lays engine.clouds over them for the run. blockloom-runtime/src/clouds.rs bakes seeded, repeating 128³ shape and 32³ erosion volumes on the GPU, raymarches at half resolution before fog, and uses the shared bilateral upsampler over the scene. WindField supplies drift. shape_volume/detail_volume swap either bake for an authored volume asset, read through pipeline::load_volume; clouds::bake_noise is the CPU twin of cloud_bake.wesl (hash included), which bake_cloud_noise writes out as strips. Change the two together. Each view keeps two color/depth histories, invalidated on edits, resize, skipped frames and fast motion. The march writes a raw frame and a second pass (cloud_resolve.wesl) folds it into the history: the history is clipped to the mean and spread of the pixel's depth-matching 3x3 neighbours, so a jittered noisy march converges rather than snapping back to its latest sample, and far clouds also take a spatial blur. Far rays cross slabs thousands of metres long, so the march gets up to double its steps there and density takes a footprint (the step or the pixel's width at that distance, whichever is wider): past what it resolves, the shape's edges soften and erosion settles to its mean (lost), rather than aliasing into per-pixel speckle. The ground shadow map passes a footprint of 0. Sun and moon have separate shadow switches. The 2048² ground transmittance map is a screen-space approximation over the combined surface lighting; it does not isolate the PBR direct-light term. Profiler readback reports primary steps and contributing samples per pixel; render diagnostics time the march. GPU tests cover sky, terrain preservation and ground shadows (cargo test -p blockloom-runtime volumetric_clouds --lib -- --ignored); distant_clouds_neither_crawl_nor_alias holds the horizon band's speckle down.

World-space texturing

Material::detail (SurfaceDetail, blockloom-core/src/material.rs) is the advanced pass over box projection, shared by materials and terrain (a terrain's texturing): stochastic texture bombing (three hex tiles, randomly offset and turned, blended by stochastic_contrast), macro variation (an image or noise multiplied over albedo), a detail normal map faded by camera distance, and a MaskStack of slope, height and cavity rule masks plus snow and wetness. Cavity is baked per terrain sample; other surfaces read none. The shader half is blockloom::texturing (core's shaders/texturing.wesl), which box_pbr.wesl, terrain.wesl and grass.wesl import. Snow and wetness also follow World.surface, volume overrides (snow, wetness) and engine.surface; materials::SurfaceGlobals is one storage buffer of weather and wind every surface reads, written only on change. DebugView::SurfaceBlend draws layer weights and masks as flat colors.

Terrain and vegetation

A Terrain component (blockloom-core/src/terrain/, TerrainSpec) is a heightfield of 2ⁿ+1 samples (129 to 4097) over size metres, height metres tall, with up to four paint layers (each with slope, height and curvature rules), optional holes, grass layers and scatter layers. Grids never live in the document: store.rs keeps them content-addressed under .blockloom/terrain (64² tiles plus a manifest), the spec names the manifest, and open_project prunes what no terrain names. store::name_of predicts a save's name without writing it.

sculpt.rs is every edit: a Brush (raise, lower, smooth, flatten, noise, terrace, paint, erase) on a BrushTarget (heights, a layer, holes, a grass or scatter density map), a Stroke of stamps applied to an Editable, and Erosion (thermal or droplet hydraulic). The editor applies strokes (paint_terrain, or the scene view's TerrainStroke), imports heightmaps through pipeline::load_heightmap and erodes, each as one undo step.

blockloom-runtime/src/terrain/ draws it, 3D only. Geometry cuts the field into chunks (mesh::ChunkLayout), each a LodGroup of levels picked by pixel_error; skirts hide the cracks between levels. Coarse levels stay resident and finer ones stream as CellTasks payloads on StreamingCells. Builds run off the main thread and are cached by spec (TerrainCache), so a rebuild that didn't touch a terrain reuses it. Collision is one rapier heightfield with holes removed. The material (material.rs) samples baked layer weights and a normal/cavity surface map at the grid UV, and the layers' maps triplanar from texture arrays.

vegetation.rs streams grass in 32 m cells within its cull distance: seeded blades (scatter::grass_blades) merged into one mesh per cell, bent by the WindField and thinned by distance in grass.wesl. Scatter layers place trees and rocks once per build (scatter::scatter_instances: density, clumping noise, slope, altitude and actor-avoidance filters, tint and scale jitter); each instance is a holder LodGroup with a child per level (LOD0, LOD1, crossed-quad billboard), procedural shapes or glTF parts drawn instanced, and an optional trunk collider.

The scene view's Brush tool (SceneTool::Brush, brush.rs) stamps onto a working copy and redraws touched chunks, weights and the surface map in place; on release it sends the stroke and files the predicted result in the cache under the spec the editor will send back, so the reload doesn't flash. EditorMessage::PreviewErosion shows an erosion filter without saving it. Triangle, chunk and instance counts reach the profiler as terrain/*.

Cloud layers

World.cloud_layers (blockloom-core/src/cloud_layers.rs) is up to four flat layers, 3D only. Coverage is an image's luma or tileable FBM baked from the layer's seed (bake_coverage), always at COVERAGE_SIZE, and remap turns it into cloud by coverage and contrast; cloud_layers.wesl mirrors remap, so change the two together. paint-cloud-layer paints strokes (paint, wrapping at the tile's edges) into assets/clouds/layer-N.png and bumps the layer's revision, which is how the runtime knows to reread a file whose path didn't change. Each stroke keeps a snapshot per revision under .blockloom/cloud-paint, and undo/redo call restore_painted to put the file back. set cloud layer lands in engine.cloud_layers (CloudLayerOverrides), laid over the project's layers each frame. blockloom-runtime/src/cloud_layers.rs loads or bakes every layer into one texture array when their keys change, and draws them in CloudPass: layers beyond the volumetric slab before CloudMarch, layers between the camera and it after, so the fog pass lays height fog over all of them. The same shader's shadow entry point multiplies the ground by what the layers let through towards the sun, drawn first. Scroll, flow and spin run on CloudOffsets::time, cloud time.

Shader library and pass plumbing

blockloom-core/src/shader_lib.rs holds Blockloom's own WESL modules (src/shaders/): hash (PCG), noise (value, gradient, Worley), fbm, scattering (phase functions, Beer, per-step integral) and frame, the standard per-view FrameUniforms. They live in core so the editor's shader check can link them; shader_lib::validate links a module against them and runs naga, which is how their tests (and the runtime's shader tests) check them without a GPU. The runtime registers each as blockloom::<name> (blockloom-runtime/src/passes.rs), so built-in passes and surface files import them like Bevy's.

passes.rs is also the shared pass plumbing, both dimensions. A camera asks for it with WorkingTargets (like DepthPrepass); the render world then gives the view a WorkingTargetSet (one full-res Rgba16Float target and a half-res scratch pair, labelled working_*), ViewFrameUniforms into the one FrameUniformBuffer filled from the extracted Environment, and ViewUpsample's bilateral upsample pipelines, queued up front so warm-up compiles them. passes::upsample draws scratch onto a full-res target, guided by the 3D depth prepass (plain bilinear in 2D). A half-res texel stands for the full-res pixel at twice its coordinate (frame::full_texel). Nothing asks for WorkingTargets yet; Phase 5's passes are the consumers.

Import roles, probes and the atmosphere slot

The asset pipeline (blockloom-core/src/pipeline/) inspects, plans and fingerprints every file. An ImportRole says what a file imports as: texture, HDR (.hdr/.exr, planned as BC6H), volume (.cube LUTs, or an image strip of slices), heightmap (16-bit images, .r16/.r32), IES profile, or light cookie. The extension picks one; a PNG heightmap or cookie is an override kept in .blockloom/pipeline.json (set-import-role), and changing a role dirties the asset. Each role's decoder is its own submodule, and a pass reads files through pipeline::load_volume/load_heightmap/load_ies rather than parsing them itself. A new format extends a role here, not a second importer.

blockloom-runtime/src/probes.rs is probe capture as a service, 3D only: ProbeService::request spawns six 90° FP16 face cameras (FACES is cubemap order, with Bevy's z flip), announces ProbeCaptured after a couple of frames, and with readback assembles the faces into a cube image. HDRI baking, reflection probes and water reflections are requests with different defaults (ProbeRequest::hdri/reflection/water); none owns cameras.

Sensors::atmosphere is the snapshot's sun/wind/fog/weather slot, versioned by ATMOSPHERE_VERSION. atmosphere::sample_atmosphere writes it at the head of every fixed tick, before any scheduler, from the blended Environment and AtmosphereSources (which Phase 5's wind and weather systems fill), and publish_sensors copies that sample rather than resampling, so a frame between ticks reads what the tick read. Every reader goes through AtmosphereSense::field: the Atmosphere reporter (VM, and compiled logic through sense) and a script's atmosphere() (READ_ATMOSPHERE). A new reading is one arm there, one entry in ATMOSPHERE_FIELDS and the reporter's dropdown in Blocks.qml.

Models, tilemaps and surface shaders

A Visual::Model draws its glTF/GLB file's first scene as a child of the actor (blockloom-runtime/src/model.rs), scaled by the look's scale, and loops the animation the look names (the file's first when empty). The authored box stands in until the scene is ready, stays for OBJ/FBX or a file that won't load, and is always what the actor collides as. ModelCache keeps loaded files alive across rebuilds so an edit doesn't flash the box.

A solid tilemap collides per tile, as the merged rects of Tilemap::solid_rects (minus its passable tiles), and nav blocks the same rects. Ray and overlap queries see them too, as a ColliderShape::Parts that tiles::publish_level fills from the live map. Every query shape is in its actor's frame and turned by its rotation (physics_query::to_local). Animated tiles cycle their frames on the wall clock; materials::animate_tiles rewrites only the mesh's UVs when a frame turns.

A custom effect's GraphEffect::starter_graph is the uniform path spelled as graph nodes, so export_shader writes it to a .wesl asset that draws the same thing. An effect whose source names a .wesl file draws with that file's graph_main(uv, time): material::surface_module wraps it in the dimension's head, SURFACE_BINDINGS and fragment tail, hoisting the file's own imports up beside the head's (WESL wants every import before the first declaration), and materials::surface_shader swaps that in through the material's specialize. check_surface_wesl parses the same module, refuses names the wrapper already has and imports the dimension hasn't loaded, and runs naga's full type check when the file has no imports, so the editor and the GPU agree on what compiles.

The built-in shaders (src/shaders/*.wesl) and that wrapper are WESL, since Bevy 0.20 hands plain WGSL to wgpu untouched: imports are import bevy_pbr::render::..., shader defs are @if(DEF), and the bind group is constants::MATERIAL_BIND_GROUP. A user's surface file is WESL too and may import Blockloom's library (blockloom::fbm, ...) and Bevy's own modules (bevy_pbr in 3D, bevy_sprite_render in 2D); a project's other files aren't modules, so package::/super:: are refused.

2D lighting

blockloom-core/src/light2d.rs is the model and the maths both halves share. A Light2D component (Light2dSpec: point or spot, color, intensity, range in pixels, falloff, cone, shadows, softness, seeded Flicker) lights the world; World.lighting2d (Lighting2d) turns it on and holds the ambient level and colour, unlit_above (the z above which nothing is darkened, so a HUD layer stays bright) and an ambient ramp that follows the director's clock. Light2dSense is the published snapshot (Sensors.light2d) that light level at and is night? read through LightLevel/IsNight.

blockloom-runtime/src/light2d.rs draws it: one full-screen Material2d quad (LightLayer, z = unlit_above + 0.5) that multiplies what is under it by ambient plus every light (shaders/light2d.wesl, up to MAX_LIGHTS). Shadows are per-light 256-sample distance maps (distance_map) built on the CPU from solid tilemap rects and casts_shadow sprites, cached in ShadowCache by a fingerprint of the light and its occluders. The shader and Light2dSense::color_at apply the same maths: change them together. A sprite's glow scales its tint past 1.0 so it feeds bloom. set ambient light to and set ambient color to (SetLook2d) land in engine.look2d for the run. Not done: per-sorting-layer lighting and normal-map lit sprites.

2D weather

blockloom-core/src/weather2d.rs is rain and snow motes as a stateless function of the clock (mote(kind, i, time, wind_x)), so a replay matches. blockloom-runtime/src/weather2d.rs draws a pool of UI nodes (z 5) from the weather blend's precipitation and snow, slanted by the global wind. Rain ends in a splash ring; leaves blow in once the wind passes 4 m/s (leaf_intensity).

The sprite atlas bakes at a per-target size (build::DESKTOP_ATLAS, WEB_ATLAS, ANDROID_ATLAS), and each TargetStatus carries a look2d_note the Build dialog shows.

The scene view's 2D aids ride TileDebug (PROTOCOL_VERSION 35): pixel_grid (one-pixel cells once the view is 8 screen pixels a cell), light_radius (each Light2d's range and spot cone), camera_bounds, parallax_ruler (a cross per layer, cool when slow and warm when fast), drawn by edit.rs (draw_level_aids, draw_actor_aids), and onion_skin (onion.rs: blue and green ghost children with the frames either side of an animated sprite's frame, its first frame in the scene view, wrapping unless the clip plays once). The 2D inspector has an All/Sprite/Anim/ Tiles/Light/Camera tab bar that filters the component cards, a live aspect thumbnail on the Look card, FlipbookStrip.qml under each animation clip (scrub, play, timings, markers) and a far-to-near layer stack on the Parallax card. The strip outlines the first enabled collider on each frame (eye button).

2D post

blockloom-core/src/post2d.rs (World.post2d) holds pixelation, per-channel levels or a palette (up to MAX_PALETTE hex colours), an ordered 4x4 dither, an edge outline and a CRT preset (scanlines, curvature, vignette, phosphor mask), plus a split that limits the look to the left part of the frame for comparing. blockloom-runtime/src/post2d.rs puts one Post2dPass fullscreen material (shaders/post2d.wesl) on the 2D world camera while any effect is on: after the tonemapper and PostLdrPass, before the UI pass so the HUD stays crisp. The look works on display values (the shader converts from the linear texture), and the CPU functions in core are the reference the shader copies: change them together. set [look] to (SetLook2d) also takes Pixelation, Levels, Dither, Outline, Scanlines, Curvature and Vignette dials, laid over the project's values for the run (engine.look2d.post).

Normal-map authoring

blockloom-core/src/normalmap.rs bakes an image's brightness into a tangent-space normal map (Sobel slope, strength 0-32, +Y up the image) beside its source as <name>_n.png, and shades a normal map under a light direction for a preview (bake_normal_map, preview_normal_map in the shell and the Sprite card, which draws the preview as a PNG data URL with a draggable light dot). Nothing uses the baked file at run time yet: 2D lighting is a screen multiply layer, so a sprite's normal map does not change how lights fall on it.

2D camera

blockloom-core/src/camera2d.rs (World.camera2d) is the finishing over the PlayerCamera's follow: axis locks, bounds with an exponential soft edge (a view larger than the bounds centres), zoom by view height or pixels per unit, pixel snap (whole-pixel zoom and position), roll, and trauma shake (trauma squared, seeded noise, decay per second). blockloom-runtime/src/camera2d.rs applies it after drive_camera; the shake offset is taken off again before the next follow (remove_shake) so smoothing never chases the tremor. All of it is SetLook2d dials laid over the project's values for the run (Zoom, ZoomHeight, Rotation, Shake adds trauma, Hitstop freezes the world on the real clock via set_paused while interface strands keep going, Bounds*, LockX/Y, PixelSnap). camera zoom and is camera at bounds? read Sensors.camera2d. Not done: a set camera target block, a when camera reaches bounds hat and split-screen.

2D movement helpers

blockloom-core/src/movers.rs: the Conveyor component (ConveyorSpec, speed along the actor's +X turned by its rotation) and Hazard (HazardSpec: knockback, lift, invulnerability, message). Phase 4's motor already does variable jump, coyote time, jump buffer, air control and platform carry; these add the rest. carry_of in blockloom-runtime/src/ motor.rs adds the belt under a motor to its carry each fixed tick, and strike_hazards (end of drive_motors) throws each motor in a hazard's touching set away (HazardSpec::throw, through the motor's push) once per Invulnerable window, on the fixed-tick clock so replays match, then broadcasts message. The hazard needs a collider (a trigger works) so touches are reported. MotorState::wall is the vertical wall pressed last tick: the on wall and wall normal x/y motor readings. InstructionKind:: WhenMotor is the when I jump/land/leave the ground/hit my head/change stance hat (Trigger::Motor, Event::Motor, codegen kind Motor, no script ABI yet); drive_motors fires each event the motor reports.

2D screen feedback

blockloom-runtime/src/screenfx.rs: SetLook2d dials Flash (seconds, fades out), FlashColor, Cover (0-1 target), CoverKind (fade, wipe, circle), CoverColor and CoverTime (seconds clear to opaque). They queue on engine.look2d.fx and drive_screenfx folds them into ScreenFx, steps it on Time<Real> (so a pause or hitstop never freezes it) and draws Bevy UI nodes over the interface (z 52 cover, 54 flash), separate from the scene switch veil in transition.rs (which it borrows normalize_kind and the iris size from). screen cover and is screen shaking? read Camera2dSense. Slow-mo, hitstop, fade-to-black and shake blocks already exist from the cinematic work. The Iris cover kind is a ring that closes to a hole. The sprite dials Pop (area-preserving squash-and-stretch, render-only, applied in PostUpdate and cleared in First) and FloatNumber (a UI label that rises and fades, floaters.rs) finish the feedback set. Not done: a script ABI.

2D animation and sprites

blockloom-core/src/animation.rs is the one animation player, both dimensions. A clip is image files or a SheetRange (cells row-major on one sheet), with per-frame durations, a LoopMode and frame markers. AnimationClip::cursor counts steps (frames shown since the start), so markers_reached fires each marker once per frame shown however long the tick. States carry transitions (clip ended, marker, trigger, or a variable compared as a number or text), a crossfade blend and root_motion; AnimationSpec::transition_from picks the first that fires, and next is still the ended shorthand. play animation names a state first, then a clip.

rig2d.rs is 2D skeletal rigs: Spine or DragonBones JSON (Rig::parse) into bones, slots, skins, one- and two-bone IK and keyed animations, y up in the actor's frame. sample gives a LocalPose, blend crossfades two, solve composes the hierarchy, runs IK and lists slot sprites in draw order. Regions draw as sprites. Spine meshes (MeshAttachment: plain or weighted across bones, linked meshes, deform keys under 3.x deform or 4.x attachments) come out of solve as rig-space vertices; DragonBones meshes are skipped. Curve eases keys: Spine's beziers (3.x normalized, 4.x in time and value per channel) and DragonBones' quad easings and chained curves. A clip plays its rig_animation (or its own name) on the rig when the rig has it, and plays its frames otherwise: that is the flipbook fallback.

sprite2d.rs is the Sprite component: flips, NineSlice (Bevy's TextureSlicer), SpriteStack slices, a palette swap (the sprite's red channel picks the column, palette_index the row), an outline, and order plus y_sort inside the Render layer (order_depth, then y_sort_depths within half an order step, so order always beats height). Y-sort ranks actors sharing a layer z and order rather than scaling height, so any spread of heights fits the band.

The runtime half is blockloom-runtime/src/anim2d.rs and sprites.rs. apply_animation_effects and step_animations run in the fixed step, so the VM and compiled logic land on the same frames, markers and transitions; they fire Event::AnimationMarker and AnimationEnded, move root-motion actors and solve rigs into RigInstance::pose. ensure_rigs loads a rig through RigCache and hangs a RigPart sprite per slot off the actor, plus a RigMeshPart for slots with a mesh, draw_rigs copies the pose on (rebuilding each mesh when its vertices move), and sync_sprites applies flips and slicing and builds stack slices and the SpriteFxMaterial quad (shaders/sprite_fx.wesl, which slices a 9-slice sprite the way Bevy's own sprite shader does, from slice_uniforms). sync_part_effects gives each stack slice and rig part its own palette quad. Their outline is a silhouette per piece (flag 8: the outline color wherever the grown piece covers; a mesh grows by nine offset copies) drawn behind every piece, so only the edge of the whole shape shows. Anything drawn through children hides the actor's own sprite with an empty RenderLayers. Sort depth is render-only: apply_sort_depth adds it in PostUpdate and clear_sort_depth takes it off in First, so no pose, drag or physics step sees it. A flipbook crossfade draws the old frame on a FadeGhost child fading out; a rig blends poses instead.

Tilemaps and levels

Both dimensions. blockloom-core/src/tilemap.rs is level building over material::Tilemap: AutotileSets pick a cell from its neighbours (16 edge cases, or 47 blob cases where a corner only counts between two filled edges; off the map counts as filled), TileBrush is every stroke (paint, erase, fill, line, rect, scatter by seeded density, variants by jitter), and Tilemap::apply_brush runs one and re-resolves autotiles around what changed. Painting an animation's frame paints its base tile, so every cell cycles in step. TileRegions mark sheet tiles as spawn, checkpoint, kill, ladder or water; a body acts on the strongest region its box touches (RegionKind::rank: kill, checkpoint, spawn, water, ladder). import_tiled_tileset reads a Tiled JSON tileset: collision shapes and passable/solid properties, animations, region properties, and edge or mixed wang sets as autotiles.

TilemapSense is a live map with its whole transform: local takes a world point into the map's frame (turned and scaled with it; a flat 2D map reads any z at its own layer), so tile at x y z, paint tile and a script's tile_at_xyz/paint_tile_at read a turned map, and a wall or floor in 3D, where the point falls on its face. region_touching takes a body's world box into the same frame (in 3D it must come within half a tile of the face) and RegionHit::at puts a respawn point at the body's own distance off the face.

The Parallax component (ParallaxSpec) scrolls a layer at 0-2 per axis against the camera's x and y, from where it stands with the camera at the origin (2D) or where the camera first stood (3D, Level::camera_origin), wraps it, and dims it towards the background by its distance from the actors' plane. The Room component (RoomSpec) is a box centred on its actor (RoomBounds; a 2D room reaches every depth, a 3D one is depth deep): the camera stays in the room its target stands in (the whole view in 2D, the camera itself ROOM_MARGIN off the walls in 3D) and slides into a new one over blend, when I enter room fires per actor on the fixed tick (the smallest room wins, and an actor first seen inside one, at the start or as a clone, hasn't entered it), a script hears the same as Event::EnteredRoom or reads it from entered_room (LevelSense::entered, published each tick), and a streaming room's maps are payloads on the Phase 4 cells.

blockloom-runtime/src/tiles.rs is the rest. Level is seeded from the document on every rebuild; paint tile and scripts write its live maps and redraw_maps (a child Mesh2d) or redraw_maps_3d (the actor's own Mesh3d, kept out of batching by TilemapLook) rebuilds a dirty map's mesh and compound collider. publish_level puts the live maps and rooms in the snapshot (Sensors.level), which tile at, room containing and scripts read. apply_regions/apply_regions_3d (one macro over each dimension's rapier types) act on dynamic and kinematic bodies by the region their look's turned box touches, pulled in 2% so resting against a cell doesn't count. Parallax is render-only like sort depth (apply_parallax or apply_parallax_3d in PostUpdate, clear_parallax in First), and a wrapped layer draws ParallaxCopy children beside itself; it leaves each layer's offset in SceneEditor::parallax, so the scene view picks, outlines and handles a layer where it shows. A 3D layer dims through its instance tint (ParallaxRecord, put back in First) or its own copy of a standard material (ParallaxMaterial), and ParallaxLayer keeps it out of batching. Streaming (plan_streaming) runs StreamingCells over XY in 2D (update_streaming_cells_2d, a cell unit being PIXELS_PER_CELL_UNIT pixels) and its usual XZ in 3D; a map inside a streaming room is meshed on a CellTasks task once a cell overlapping its room is active, and its drawing dropped (collision kept) once none is: a 2D one spawns bare, a 3D one loses its Mesh3d until then.

The scene view's Tiles tool (SceneTool::Tiles, SceneView::tile_brush) paints where the pointer meets the map's own plane, turned or in 3D, on the level's copy live and on release sends TileStroke (the brush and its cell segments), which commands::tile_stroke runs on the saved map as one undo step; Pick sends TilePicked, which the Game view's palette takes up through state.picked_tile. SceneView::tiles turns on the collision, region and room overlays and parallax preview. The shell has paint-tiles, import-tileset, tilemap-stats and add-autotile.

How a project runs

  1. Play hands the runtime the whole project (EditorMessage::Load) and starts it. Nothing is shared but that message - even in-process, the world gets its own copy - and the runtime owns the world from then on.
  2. vm::compile flattens each actor's canvas into a Vec<Step> with jumps - a nested tree can't be suspended mid-body, but a program counter can. Header strands become entry points keyed by their trigger. Each value slot is lowered the first time it runs (vm/lower.rs): variable names become interned slots in Variables, and pure operators over constants fold away, so evaluating one hashes nothing.
  3. Simulation runs on Bevy's FixedUpdate: a constant-rate step (FixedMain catches up whatever the display does) that pulls the project's world.fixed_rate
    • set in Project Settings and applied by pump_editor/dim2|dim3::sync_timestep - so blocks and physics advance on the same tick whatever the frame rate. The per-frame Update publishes the sensor snapshot (sense::publish), turns input and rapier contacts into vm::Events (at most once a frame, so a few sunk fixed steps never repeat a keypress), and reads Vm::tick's results next step.
  4. The runtime applies those effects to the ECS - actors made and unmade in world::apply_lifetimes, shared ones in world::apply_common, physics and material ones in the dimension's own module - and reports says, errors and a periodic status back to the editor. Every actor carries a PhysicsPose/PrevPose pair, and record_poses (FixedPostUpdate) + interpolate_poses (Update) draw each between fixed steps so fast displays don't see them - physics bodies from the pose physics wrote, and a move/glide sprite from the pose its step's effects pushed it to, both just as smooth as a rolling ball. restore_poses puts the settled pose back at the head of every fixed step, so simulation builds on where the actor actually is rather than on the frame the renderer drew.

Scripts yield the way Scratch's do: at a wait, and once per loop iteration. That one rule is why forever costs one step per fixed tick instead of hanging the process, and it's checked directly in blockloom-core/tests/vm.rs.

The VM holds Rcs, so it is a !Send Bevy resource - which is exactly right: every system touching it is therefore scheduled on the main thread, the same thread the thread-local sensor snapshot lives on. Keep it that way.

Simulation and presentation registration

blockloom-runtime/src/simulation.rs is the half of add_world that a server could register alone: add_simulation inserts the engine, Dimension, PendingEffects, the nav mesh, both Rapier pipelines (in the fixed schedule, with the collision hooks), motors, controllers, constraints and the whole fixed-step simulation chain, plus FixedPostUpdate's parenting, pose recording and contact tracking. The physics debug renderer stays with presentation. Each fixed-step simulation system sits in a SimStep set and the sets are chained in the order the old single chain ran. The systems that used to sit inside that chain but present something (interface bindings and effects, exposure, HDR, lights, ray tracing, fx, sound, input effects) are registered by add_world in lib.rs and order themselves .after/.before the steps they used to sit between, so the order is unchanged. A new simulation system is a SimStep member; a new presentation system that reads an effect goes between two steps in lib.rs.

Still interleaved, and the next extraction: the Update chain (pump_editor, rebuild_world, gather_volumes/blend_environment, publish_sensors) and rebuild_world/apply_lifetimes themselves, which spawn meshes and materials next to the entities. The sim systems therefore still take the asset stores (AssetServer, Assets<Mesh>, Assets<StandardMaterial>, ...) as required parameters. The tests in simulation.rs supply them as plain CPU-side asset collections over MinimalPlugins, which is the headless footing: a 2D and a 3D project step on the fixed clock with no window or device, the same project steps to the same place on every run, and a project compiled to native logic steps to the same place as the VM.

volumes::VolumeEye says where volumes are weighed: the world camera (the default), a named actor or a point, so a world with no camera blends the same atmosphere for sample_atmosphere.

Per-frame cost

publish_sensors rebuilds the snapshot every frame, so it is the first thing a crowded scene pays for. It refreshes last frame's ActorSense entries in place (sense::take_actors, then clone_from) rather than building new ones, and Engine::actor remembers each authored actor's slot in the document list so per-actor lookups stay O(1). Own-actor reporters read through sense::with_me, which borrows the entry; never clone an ActorSense to read one field. By-name lookups (Sensors::find, count_named) scan twice and then use a lazily built NameIndex for the rest of the snapshot, and a name shared by clones answers with the smallest id. Writes through &mut Transform mark it changed, so interpolate_poses and restore_poses assign only when the value differs. docs/performance.md has the measurements, how to rerun them (cargo bench -p blockloom-core --bench vm, the ignored sensor_publish_cost test) and the open list; GPU numbers there are inferred until profiled.

Sound

blockloom-core/src/sound.rs is the model: a SoundBus (Master, Music, Sfx), a SoundMixer of one linear gain per bus saved on the World, and the 0-100 block scale both ways (user_to_gain/gain_to_user). Blocks are play sound (global), play sound at (positional: follows an actor, panned by Bevy spatial audio with per-frame distance falloff), stop sound (empty stops all), set sound volume/pitch (live voices of one file), and set bus volume (window-global like gravity). blockloom-runtime/src/sound.rs owns the voices: one entity per play with an AudioPlayer, handles cached by path, oldest stolen past 8 of a file or 128 total. Sounds ignore the pause freeze (a menu click still clicks), and stop all silences them. Reporters are is playing? and bus volume, read off the published snapshot.

Cinematics

A cutscene is a named reel on the World (blockloom-core/src/cinematic.rs, a Cutscenes scene component): shots of a camera actor, an FOV and a length, each with an optional dolly path of look-at/roll/FOV keys eased between, plus signal, slow-motion and volume-weight keys. blockloom-runtime/src/cinematic.rs plays it on the real clock ahead of the sensor publish, so it runs through pause game like UI strands do. Shots drive the world camera after the rig and the room confine; signals fire when cutscene signal strands; slow-motion keys scale the virtual clock the fixed clock follows, so blocks and physics keep their tick; volume keys borrow weights and restore them at the end; skip cutscene fires the remaining signals before when cutscene ends. Trauma shakes the camera on seeded gradient noise and decays; letterbox bars (z 45) and a black/white fade wash (z 47) ease in over the interface but under the scene veil. Blocks are play cutscene, skip cutscene, shake camera by, set time scale to, hitstop, set letterbox to and fade screen to, with is cutscene playing? and cutscene time reporters. Reels are authored as JSON through set-scene-component; there is no visual timeline editor.

Building a game

Build is not Export. Export writes a .blockloom file for somebody else's editor; Build makes a runnable folder and a ZIP somebody can share without Blockloom at all. Windows keeps the player and game/ together. Linux adds a portable shell launcher, a .desktop entry and a PNG icon. macOS uses the native bundle layout:

Pond Game/
  Pond Game.exe        the player: `blockloom-runtime`, renamed
  Pond Game.ico        the project icon, also embedded in the executable
  game/
    game.pack          the document, and the format version it was written at
    assets/...         the project's assets, minus the script sources
    .blockloom/atlas.* the Image looks baked into one sprite sheet
    .blockloom/build/  native blocks and script libraries
Pond Game.app/
  Contents/
    Info.plist
    MacOS/Pond Game
    Resources/
      GameIcon.icns
      game/...

blockloom-core/src/build.rs lays that out and commands::build_game drives it. The player finds game/game.pack beside its own executable, or under the app's Contents/Resources on macOS. That game/ is handed to the runtime as the project folder, which is why assets and native libraries keep the spelling they have inside a project.

Project Settings holds one image asset for build branding. Packaging converts it into a multi-size Windows ICO, a macOS ICNS and a Linux PNG; an empty setting uses Blockloom's bundled icon. The ZIP contains the platform-named build folder as its top-level entry and preserves executable bits for Linux and macOS.

player::Launch is the one fork: a pack beside the binary means player mode, which takes its dimension from the document rather than --mode, presses its own green flag, never reads stdin, reports nothing (bridge::attached is false, so the status corner and the editor handshake are skipped), and exits when the world stops, since nothing can press Play again - stop all is how a built game quits. --play <folder> runs a build without renaming anything.

A build always ships the document so the VM remains a fallback. With "Compile blocks for maximum speed" enabled, it also ships one optimized native logic library and the player schedules that instead. The option defaults on when the project and target toolchain support it. Scripts ship as the libraries the editor already compiled, and one that won't compile fails the build rather than shipping an actor that quietly does nothing.

Which platforms an install can build for is a question about what it has beside it. The player is a native binary Blockloom can't produce, so one per platform is staged under players/<triple>/ next to the editor: just player puts this machine's there (the dist profile - fat LTO, one codegen unit, stripped - while just build keeps the release profile's quicker link for the edit-run loop), and just stage-player <triple> <file> takes one built elsewhere. The machine doing the building always has one, since the runtime the editor plays with is a player. Host exports use the newer of the staged player and that runtime, so a stale staged copy cannot undo a recent editor/runtime fix. Scripts are native too, so a project with one can only be built for a platform this machine's rustc can compile for; script::compile_for cross-builds them into .blockloom/build/<triple>/, leaving Play's own build where the runtime has always looked for it. build::targets answers both questions at once, which is what the Build dialog lists, with the reason attached to every platform it can't offer.

A build folder is named for the project and the platform - Pond Game (Linux x64) - because one output folder holds a build per platform, and three folders called the same thing would be three chances to ship the wrong one.

The web player

The Web build target (wasm32-unknown-unknown) makes one .html holding everything: build::build_web lays the game folder out as a native build does, then web_build::page gzips the staged player (the wasm and its wasm-bindgen glue under players/wasm32-unknown-unknown/, which just web-player stages), the pack, every game file and each script's wasm module into one archive, base64'd into the page. The page unpacks it with DecompressionStream, imports the glue from a blob URL, compiles the script modules and, on a click (audio needs the gesture), calls web::start_game. So it opens from disk as well as any static host. Always SDR, always the VM.

The player renders through WebGPU (Bevy's webgpu feature, set for wasm in the runtime's Cargo.toml; WebGL2 has no compute for the sky, fog, cloud and luminance passes). start_game mounts the files in blockloom_core::vfs, which every direct read a web run can reach goes through (vfs::read rather than std::fs::read), and web::GameFiles serves the same table to Bevy's asset server; the game folder is the empty path. Saves go to localStorage, pointer lock is asked for on a click on the canvas (web::want_pointer_lock), and the canvas follows its parent's size. web::game_actors reports actor positions, which is what just web-smoke checks, since headless Chromium can't screenshot a WebGPU canvas.

A script on the web is its own wasm module, not part of the player: a module can't call another through a function pointer, so there the script imports the three host calls from abi::WASM_MODULE, each taking a pointer to an abi::WasmCall in its own memory, and the entry points get a null HostApi (the prelude's web module swaps in its trampolines). script.rs's browser module instantiates each module once per file and answers the imports with the same number_for/text_for/act_for the native extern "C" calls use. A panic aborts on wasm, so the prelude's panic hook logs the message first; the trap then stops that script for the rest of the game, and its error names the panic.

Chrome's WGSL compiler holds shaders to the spec where naga doesn't, so pbr_patch.rs also rewrites the let texture = pbr_bindings::... lines in Bevy's material shaders on wasm (patch_handle_lets).

Compiling the blocks

blockloom-core/src/codegen/ emits a project's blocks as Rust source: an expression becomes an expression, a variable read becomes a call rather than a hash lookup, and an actor's canvas becomes a function. It is meant for the same no-Cargo rustc pipeline the scripts use, so a built game can carry native logic beside its native scripts.

A strand can be suspended, so it isn't a straight run of Rust: it comes out as a match over the program counter, over the very same flattened Vec<Step> the VM walks. Straight-line blocks fuse into one arm, and every point the VM can give the frame back - a wait, a glide, each iteration of a loop - is an arm of its own that picks up where it left off. What the VM works out at run time on a frame stack is known here while emitting, so a repeat counts down in a flat slot the entry table sized and an escape loop is just a jump.

One function covers a whole actor rather than one strand, because every strand and every custom block body live in one step list with one set of numbers, and a custom block called as a statement is a jump into somebody else's region with a return address pushed. The VM's immediate flag - a reporter body run to completion in place - becomes a second function over the same steps, with its own state, so a reporter may call another or itself and the strand's program counter never moves. Each actor is therefore emitted at most twice, however many custom blocks it has.

The rule it has to keep is that a compiled program and the VM ask the world for exactly the same things in the same order, on the same tick - including the mistakes, since a bad slot reports itself once and stands a zero in its place, while a slot that evaluated fine but isn't a number is silently zero. tests/codegen.rs is that rule: it runs a project both ways off one clock, prints what each one asked for and which tick it asked on, and compares line for line. Add a block to the emitter and add a case there, or the two halves drift and a compiled game stops meaning what the played one meant.

The one difference on purpose is the VM's per-tick step budget, which compiled strands don't count against. Every back edge belongs to a loop and every loop yields, so a compiled strand can't spin; the budget only catches ten thousand straight-line blocks in a row, and a counter on every block would cost what compiling was for. A reporter body does count, since nothing in one yields and the budget is the only thing that ends a runaway.

codegen/runtime.rs is the support code a generated program is built on - the value type, the operators over it, the State a suspended strand is kept in, and the Host trait it asks through. Like script/abi.rs it is compiled twice, once into blockloom-core so the tests can hold it against Evaluated and against the VM's own limits, and once as text into every emitted program. Anything that reads the world or the clock is a question for the host, so random and current time go the same way sensing blocks do: one run of a game has one source of each rather than two that disagree.

Three things shape the emitted code. Every slot is read into a let before the act that uses it, because reading a slot borrows the host and so does handing it something to do. and/or take their second operand as a closure, because the VM's short circuit is observable: false and <a bad slot> reports nothing. And every variable, parameter and reporter call is hoisted into a let ahead of the expression, because the VM runs each of them before the operator over it: a reporter on the side and never reads still runs, and still does whatever it does to the world.

The actor is a value rather than a constant, which is what lets one emitted function cover an authored actor and every clone of it: a State carries the id it is running under, and Entry says which authored actor's strand it is. The generated program keeps its own Actors table, the same one the VM keeps and for the same reason - delete and create a clone of name an actor the way every block does, and both have to be answerable before the host has done anything about them. So the program mints the id, copies the scheduling and queues the copy's when I start as a clone strands for the top of the next tick, and the host is left with the entity. Each scene gets its own names and entries tables (NAMES_n/ENTRIES_n) under one SCENES index, because an empty canvas still answers to its name. The runner runs the active table and load_scene swaps it the way Vm::load_scene does, so a compiled game switches scenes without falling back to the VM. A clone or a deletion from outside the program - a script's - comes in through fire as a Cloned, Created or Deleted kind instead.

What it won't compile is a custom block that can reach itself through statement calls: its loops would share one set of counters where the VM gives every invocation a frame. Unsupported refuses the whole project rather than emitting half of one, so the Build dialog can disable native logic and name what sent it there.

vm::Variables is the live variable home shared by either scheduler. Generated logic exports one runner behind the ABI in codegen/runtime.rs, and blockloom-runtime/src/logic.rs loads it, translates events and effects, and answers variable and sensing callbacks. Editor Play stays on the VM as the reference behavior. A packaged player uses the native runner whenever its build carries one, and falls back to the VM when it does not.

Interface

A game builds its HUD and its menus out of blocks, in screen space, over the world. blockloom-core/src/ui.rs is the vocabulary: seven element kinds (Panel, Label, Button, Image, Input, Slider, Toggle), a 9-point anchor, and the properties set [prop] of (id) to can write. An element is named by an id string the project invents, and show makes one or updates the one that id already names - so a HUD strand can rebuild itself every frame without piling up. hide takes one off the screen without forgetting it, children and all; delete forgets it. A parented element flows after its siblings inside its parent's vertical stack and its own placement is ignored, which is what makes Resume / Settings / Quit a three-block menu.

Every show block spells its id, caption and placement the same way, so the element kinds share one set of field ids (UiId, UiContent, UiX, ...). The JSON field is element rather than id, because a flattened instruction already carries its own id on the wire (see wire.rs).

Three properties have no show row and so live beside the style rather than on the element: a slider's step, and a text input's allow and max length. A HUD strand that re-shows its own slider every frame would otherwise wipe them. step rounds a slider's number to a multiple of itself measured from min, which is also applied to a number written straight in, so a step that doesn't divide the span leaves the far end short. allow names a character set (any, numbers, digits, letters) by a word somebody types, and anything else reads as any - a typo shouldn't deaden a field. A refused keystroke is simply not there: no error, since somebody holding a key down means no harm by it.

Five reporters read the interface: value of (id) and text of (id) (which is a label's words, or an empty input's placeholder), is (id) shown?, does (id) exist? and the focused element. hide leaves an element existing but not shown; only delete takes it out of both. An id nothing answers to is an error for the first two, the way a missing actor is, and plainly false for the other two.

blockloom-runtime/src/ui.rs is the id map and the rules over it - the hit test, the subtree walk, the anchoring - Bevy-free but for the entity handle, so they unit-test without a window. overlay.rs is the Bevy half: apply_ui_effects folds the fixed step's effects into the map, and draw_ui spawns, despawns and restyles. An element's anchor is a percentage plus a UiTransform of its own size rather than a worked-out pixel offset, because an auto-sized label isn't measured until Bevy has laid it out - and a window resize then recomputes for free.

Clicks route through the interface first (world::detect_clicks): the topmost visible element whose rectangle covers the pointer wins, and only what nothing wanted reaches the world picks. A visible modal element swallows the rest, so clicking beside a pause menu never fires the gun behind it. A click on a text input hands it the keyboard; while it holds it, game strands see no keys at all. focus (id) and clear focus move the keyboard without a click, and the input holding it is drawn brighter, since otherwise nothing would say where the typing is going.

pause game freezes the world: no world strand advances, no physics steps, no key or collision event queues, so resuming never bursts. The one fork is that strands a UI event started keep ticking - otherwise a pause menu's own buttons would be dead - and they run on the wall clock, so a wait 1 blink on a frozen menu still blinks. A pause game in a world strand stops that strand where it stands, the way delete myself does; in a UI strand it doesn't. world::set_paused is the one place that flips it, whether the editor's Pause button or the block asked.

The wall clock is real time: Engine::wall_time counts Time<Real> seconds from begin_run's wall_started_at, so a pause, a game speed below 1 or a stalled frame doesn't slow or rewind it (it used to be the fixed clock minus the run start, which jumped back by the paused span on resume). Run clocks read elapsed_secs_f64, since the f32 form loses about a quarter millisecond an hour in.

Pointer lock is fully manual: game code unlocks around a menu and re-locks on close. In the Game view the editor holds it on the world's behalf (see Game view above). The one safety net is that showing a modal while the pointer is locked logs a warning, since a locked hidden cursor can't press anything.

Interface editor

Plan and progress: docs/interface-editor-plan.md. The Interface workspace (UiDesigner.qml) draws no layout of its own: preview_interface runs the document through the player's retained widget renderer in an idle design session and the reply carries each widget's computed transform, size, clips, visibility and paint order, tagged with a document revision and viewport generation. QML picks and draws handles from that geometry only.

Edits are blockloom_core::ui_framework::UiEdit (apply_edit validates and swaps atomically): Move, Resize, SetProperty (canonical paths), Reparent (free or flow), Reorder, Create, Delete and Duplicate. A gesture is begin_interface_edit -> update_interface_edit (replaces the draft, never saves) -> commit_interface_edit (one undo step) or cancel_interface_edit; the same commands serve shell and MCP. Create appends a widget (a Canvas parent forces it free, any other parent flow), Delete removes the subtree and clears scroll_targets into it, Duplicate copies the subtree after the original as new_id and new_id.<old id>, remapping parents and scroll_targets inside the copy only. Blocks and scripts that name the original ids are not rewritten. set_interface stays the whole-document import path. The hierarchy is a tree built in QML from sibling order (treeRows) with search and fold state; Lock (lockedIds) is editor-only and never saved. UiDocument.version (UI_SCHEMA_VERSION) is stamped by migrate() on load and in set_interface; a newer document is refused by validate, not rewritten. Editor hide travels as InterfaceDesign.hidden and only affects the design preview. Batch applies several edits atomically; property_metadata() (interface-properties) is the table of SetProperty paths and is kept in step with UiPropertyEdit by a test. Selection is selectedId plus extraIds in QML (Shift-click, marquee) and Delete/Duplicate send one Batch. Group drag, Align and Space H/V (arrange) compute in QML from runtime geometry and send a Batch of Moves. Content and style fields (style, class, bindings, items, tooltip, scroll_target, tab_index, transition, element.range) plus SetDocument and SetClass are typed too, so set_interface is only the import path. Committing with a design session open sends no Load. A new edit shape needs the update-interface-edit summary in shell.rs and a case in tst_InterfaceViewport.qml's mock backend.

The designer loads interface_properties on start and applies(path) hides inspector rows that do not suit the selected widget kind (Modal, scroll target, Items).

Components: UiEdit::SavePrefab/InstantiatePrefab/DeletePrefab/DetachInstance over UiDocument.prefabs; an instance root has instance_of, updating rebuilds instances but keeps widgets changed from the old source, and a missing prefab fails validation. Image widgets take UiPaint.image_fit (Stretch, Sliced{border}, Tiled), applied in overlay.rs::image_node.

Android devices and the emulator

The Devices tab (DevicesPanel.qml, DevicePanel.qml) lists what adb sees, boots emulators and shows a device's screen. blockloom-core/src/android.rs holds the adb/AVD plumbing and android_tuning.rs what makes a boot fast.

An embedded emulator boot is -no-window, and -gpu auto falls back to software drawing once the window is hidden, so android::start_emulator always passes -gpu host (BLOCKLOOM_EMULATOR_GPU picks another mode from tuning::gpu_mode's list), plus -cores/-memory raised (never lowered) from the AVD's own config.ini, -no-metrics, and -grpc <port>. Its output goes to emulator-<avd>.log in the data dir and the child is tracked, so a boot that dies (no hypervisor, a bad GPU mode) shows up as EmulatorState.failure on the next status read instead of a five minute wait. EmulatorStatus.acceleration is emulator -accel-check's answer (/dev/kvm hints on Linux), asked once when good and every 30 s when not. The x86_64 image can't be accelerated on an arm64 host; that is reported, not worked around.

The screen is a push stream, not polled commands. blockloom-app/src/screen/ is one Session per watched device, owned by AppBridge (watchScreen, screenInput, screenJson) rather than Backend::dispatch, so a slow command on the backend's single worker never delays a tap. A session picks its transport on its own thread: the emulator's gRPC service (grpc.rs: streamScreenshot scaled by the emulator, sendTouch for real pointers; messages are declared by hand with the field numbers of emulator_controller.proto) when the editor started that emulator and wrote its port (android::emulator_grpc_port), else one long-lived screencap -p loop over a single adb pipe (stream.rs, PNGs framed by their IEND, the newest kept and older dropped). Frames leave as JPEG data URLs (frames.rs). Touch reaches the device through Routes: gRPC once its stream is up, else one persistent adb shell (shell.rs) typing input motionevent DOWN/MOVE/UP (or tap/swipe where the device lacks it), which also takes keys and counts finished commands so moves coalesce instead of queueing. The android_mirror_* commands stay for the shell and MCP, one grab per call.

Frontend (blockloom-qt/qml/)

Main.qml holds the one copy of backend state (appState, parsed from bridge.stateJson) and invoke(command, args, done, failed), which every other file calls. Blocks.qml (a singleton) is the single wiring point into blockstitch and where a block's row comes from: every block is described once as a list of pieces (a label, a value slot, a dropdown, a text field) and registered with blockstitch's BlockRegistry, which both the canvas and the palette read. Adding a block means adding a variant to InstructionKind, a row and an icon in Blocks.qml's buildRows(), a label in its labels, a field id if it has value slots, and a Step/Effect if it does something new - not a new QML file. BlockHeader and CallBlock take their row from a BlockDef rather than their type, which blockstitch draws itself.

Keep the canvas's inputs identity-stable, since any change rebuilds what is bound to it. Main.qml's reuse swaps every unchanged part of a new snapshot for the old object, and Blocks.qml bumps BlockRegistry.revision only when optionSource (the names its dropdowns list) changes.

The asset tray along the bottom (AssetTray.qml) is a file manager over the project folder: it lists, makes, imports, renames, moves and deletes files, and an asset dragged out of it lands on any AssetField.qml (the Look component's Image, the Script component's path). Asset paths are relative to the project folder with forward slashes (assets/sprites/player.png), the same spelling Script uses, and Project::repoint_asset follows a renamed or moved file through the document so the actor using it doesn't end up pointing at nothing. The listing is not part of the state snapshot - a folder changes for reasons the editor never hears about - so the tray re-lists after each of its own changes. Scenes are tray files too: one .blockscene per scene, made from the tray's New scene item, renamed like any other file (the filename is the scene name), and opened with a double-click (or the Open scene menu item). Project settings names the default scene, which is what a fresh open - and a built game - boots into; the actor list's scene box only shows which scene is open.

A project is a folder, not a file: <name>/project.blockloom beside an assets/. The folder sits wherever the New Project dialog was pointed (~/Blockloom/projects by default), and the app owns the folder's name - it follows the project's, so renaming a project moves its folder. <data dir>/blockloom/projects.json remembers which folders the Dashboard lists; names and dimensions are read back off disk, never cached there. With BLOCKLOOM_DATA_DIR set, both that file and the default project location move under it. blockloom-core/src/library.rs is that list, including the one-time migration of pre-folder <id>.blockloom files.

The app opens on the Dashboard (Dashboard.qml); the editor appears once a project is open, and state.project being null is what decides which of the two shows. Every edit is still written to disk right after it lands.

Known gaps

  • The interface has three global themes plus per-element overrides and scrollable UI lists. Variables persist only when a save variable block or the matching script call writes them to per-player save data, in the run's save slot (switch save slot to, save slot/save slots; one file per slot, the default keeping the legacy file). Interface strings come from the project's string table (set language to, language, text for key), edited through set-locale and friends; scripts drive both through ABI 47 (switch_save_slot, save_slot/save_slots, set_language, language, text_for).
  • A text input has a caret, shift-selection, IME composition, pointer placement and drag selection. The caret, selection and composition underline are drawn by input_caret.rs from the text layout's glyph geometry (parley, via ComputedTextBlock::buffer); the shown text only splices the composition in. Backspace rubs out, Escape and Enter let go, and every other character key appends - subject to the input's own allow and max length, which is all the validation there is. Backspace ignores both, so a rule written after the typing doesn't trap what is already in the field.
  • A clone copies the template as the editor authored it, standing where the template stands now. What attach/detach did to the template since Play doesn't carry over - re-attaching a component has always meant the authored one.
  • Building for another platform needs its player staged by hand, and a scripted project also needs that target's std and a linker for it. The web player is staged with just web-player, and web builds need the wasm target's std for scripts (rust-lld ships with it).
  • A web build loads whole before it starts: no streaming, and base64 costs a third over the gzip'd parts. Blocks run on the VM there, and scene luminance, GPU readbacks and the profiler behave as the browser allows.
  • Recursive statement-shaped custom blocks fall back to the VM because their loop counters still need to move onto each call frame.
  • A script needs a Rust toolchain on the machine that presses Play, which a packaged install can't assume. The script editor is a plain textarea, and a script's errors only show in the run log.
  • A script can't be attached mid-run: its library is opened when the world is built.
  • say shows as a camera-projected speech bubble over its actor in both 2D and 3D, and is also recorded in the editor log. Bubble styling is saved on the world with an optional font asset path, which no inspector row exposes yet.
  • A reporter-shaped custom block runs to completion in place, so a wait inside one passes straight through.
  • The scene view's camera starts over whenever the world does (a dimension switch, reopening a project).
  • Rooms are axis-aligned boxes: turning a room's actor doesn't turn the room.
  • 2D rigs draw Spine regions and meshes; DragonBones meshes, clipping and path attachments draw nothing. A rig's or stack's outline is one silhouette per piece, so a translucent outline color darkens where pieces' silhouettes overlap.

Interface effects and animation preview

UiFx (opacity, offset, rotation) is visual state laid over an element after layout: UiWidget.fx holds the authored start (typed edit path fx), and the Opacity/OffsetX/OffsetY/Rotation UiProps write it at run time and are animatable tracks. It composes with the anchor without touching its UiTransform translation: offset adds to the margin (anchored), insets (absolute) or relative insets (flow child) in layout_node, rotation sets the same transform's rotation (overlay::apply_fx_rotation), and opacity multiplies down the tree (UiManager::opacity_of) into background, border, shadow, image and text colours. Not faded yet: slider tracks and fills. InterfaceDesign.animation ({name, time}, PROTOCOL_VERSION 37) freezes a document animation at a time in the design session through UiManager::sample_animation; the editor's timeline Preview/scrub uses it and pauses editing while it is on. Nothing is saved.