This file provides guidance to coding agents (Codex, opencode and others) when working with code in this repository.
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.
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 pagejust 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.
- 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.
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.
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-qmlat the repo root, modulecom.blockworked.Blockstitch), which lives on blockstitch'sqmlbranch.
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 oneA 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 scriptSyntax: 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.
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.
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(packageblockloom) - the editor window and the app's entry point: Qt Quick over cxx-qt. It ownsblockloom-appdirectly - there is no daemon.src/app_bridge.rsis the one QObject QML talks to:invokeCommandruns aBackend::dispatchcommand on a worker thread, in order, and answers withreplied(token, {ok, result, error}); state the backend publishes lands in thestateJsonproperty, coalesced per burst.src/preview.rsfollows a child runtime's MJPEG preview stream intopreviewFrame.src/game_view.rs/.cppare the in-process Game view, andsrc/pointer_lock.cppits pointer lock.blockloom-app- the backend:src/commands.rsholds every command,src/dispatch.rsmaps command names + JSON args onto them,src/state.rsholdsSharedState/AppStateand the snapshot the frontend gets, andsrc/runtime.rsis the game runtime's leash. No Qt dependency. Commands publish changes throughAppHandle, 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_processis the child process and the built player,embed::runthe windowless in-editor world, and both build the same world throughadd_world.--mode 2d|3ddecides which physics/render pipeline is built, so the editor restarts it when a project switches dimension.src/world.rsholds the dimension-agnostic systems,src/dim2.rs/src/dim3.rsthe sprite/bevy_rapier2dand mesh/bevy_rapier3dhalves. The same binary is what a built game ships: with a pack beside it, it loads that instead of waiting for an editor, andsrc/player.rsis 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. BumpPROTOCOL_VERSIONwhenever 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 itsBlockKindimpl),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) withbuild.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), andwire.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/rapier3dat the revbevy_rapier3duses) answering the rows ofdocs/physics-compatibility-ledger.md:materials.rs(static/dynamic friction through amodify_solver_contactshook),compounds.rs(ownership, mass),events.rs(the Unity pair matrix, per-step event delivery),queries.rs,ccd.rs(what each CCD flag set holds) andcontroller.rs(Rapier'sKinematicCharacterControlleragainst Unity's skin width, step offset, slopes, overlap recovery and platform carry). Findings to keep in mind before touching physics: queries are stale untildetect_collisionsruns, and syncing a never-stepped velocity-based kinematic body freezes it untilwake_up; Rapier'soffsetis Unity's skin width but its autostep is not the step offset, and overlap recovery is capped per call; parry's capsule-vs-boxintersect_shape/contactflicker within a centimetre of touching (usedistance); 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 -- --nocaptureprints the quoted numbers.blockloom-net- native multiplayer transport (Phase 0 ofdocs/multiplayer-and-embedded-server-plan.md, findings indocs/multiplayer-phase0.md). Quiche over a nonblocking UDP socket, driven bypoll()from one thread, carrying opaque bytes:ServerandClientwith ALPNblockloom-game/1, a self-signed in-memoryIdentitypinned by its SHA-256Fingerprint(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 andkick.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 iscargo test -p blockloom-net --test loopback -- --ignored --nocapture matrix.blockstitch-core(sibling repo, see above) - the shared block-editor backend.valueis theValue/Opexpression system, extended by an app throughregister_operators(Blockloom registers its sensing reporters inblockloom-core/src/value.rs);graphis the document model (BlockGraphof strands, instructions, comments, variables, custom blocks), generic over the app's own instruction enum via theBlockKindtrait;editoris canvas addressing (PathStep/ValueLocation), undoHistory, and every structural edit a drag/drop/typed character performs. Blockloom implementsBlockKindforInstructionKind(blocks.rs), names its value slots infields.rs, and gives every actor aBlockGraphof its own.
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.
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.
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) andActorComponent::Collider { collider }(ColliderSpec: aColliderGeometryof a savedColliderShapeorFromLook, center, rotation, material, trigger, layer, overrides). One body per actor; colliders repeat, soComponents::collider(id)/colliders()address them byColliderIdand the name-basedget/removerefuse an ambiguous repeated name. Ids are generated, unique per scene, and kept by undo (snapshots), save/load and reparenting;Actor::refresh_physics_idsgives a duplicate its own. A capsule'sheightis end to end.- Ownership (
ownership.rs,PhysicsOwnership::resolve): a collider belongs to its own actor's Rigidbody or the nearest ancestor's (viaParent); 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 matricesplace_authored_childrenbuilds, honoringParentoffsets. 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,CompatibilityProfileLegacy or Unity,MaterialLibrary), written only when not default so an untouched legacy project re-saves byte for byte. A newerschema_versionfails to load with a clear error. Built-in materials (Default, Ice, Rubber, No Bounce) are referenced by name; stored ones byMaterialRef::Asset. 3D combine priority is Maximum, Multiply, Minimum, Average; 2D uses the geometric mean of frictions and the larger bounce. meta.rsholds 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.rschecks 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 legacyBodybeside 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_previewshows what each legacyBodybecomes (Rigidbody plusFromLookcollider plus a stored legacy material, ids{actor}-rigidbody/{actor}-collider);migrate_physicsapplies 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 readsphysics-check,physics-ownership,physics-properties,physics-migration-preview. A refused edit leaves no undo step. The genericadd|set-actor-componentrefuse 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_VELOCITYand the sleep and solver defaults). Tests:cargo test -p blockloom-core physics(tests/physics_gate.rsholds the gate fixtures) andcargo test -p blockloom-app --test physics.
Phase 2 of the physics plan. Core decides, the runtime installs.
- Core (
blockloom-core/src/physics/):plan.rsPhysicsPlan::build(actors, mode, settings)(alsoScene::physics_plan) turns a scene intoBodyPlans andColliderPlans and carries every problem (issues, a superset ofvalidate_scene).geometry.rsapplies 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.rsis the 32-layer matrix per dimension (LayerSettings, saved onPhysicsSettings.layers, written only when changed) plusColliderFilter/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_exactsays when two group masks cannot say it (an include, or an exclude with a priority) and the plan setsexact_filtering.ops.rsis theForceModemath (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 / volumeover 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_offsetis Rapier'sContactSkin(times pixels per metre in 2D),queryablefilters ray/cast/overlap queries, and a body'sinterpolationis aPoseSmoothingcomponent read byworld::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):installruns at the end ofrebuild_worldfor the scene's plan (an erroring plan installs nothing and logs). A body getsRigidBody,Velocity,ExternalImpulse, gravity scale (Use Gravity off is scale 0), damping,LockedAxes,Sleeping, CCD, extra mass andRigidBodyDisabledfor Simulated off. Each collider is its own child entity of its frame entity (PlannedCollider), withColliderScale::Absolute(the shape is built at its final size), density,Friction/Restitution, collision groups,Sensorfor triggers (ActiveCollisionTypes::all() - STATIC_STATIC),ColliderDisabled, and one-way (2D). Removing a collider entity is how a shape goes away;refresh_massesnudges 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 belowSTICK_SPEEDof sliding at the contact point, dynamic above; only on colliders whose two coefficients differ) and the exact pair test; 2D reusesOneWayHooks(now also the pair test). 2D friction is Rapier's geometric mean and bounce its maximum, set per collider.PhysicsLayersis the resource the hooks read. Velocity caps and frozen axes are enforced byclamp_velocitiesbefore 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 isCcd; Speculative isSoftCcdwith a 0.5 m prediction. See the ledger's CCD table for what each holds. - Solver overrides:
solver_iterationsbecomes Rapier'sAdditionalSolverIterationson the body (Rapier's solver has no per-body iteration count, so this is extra substeps, not a Unity iteration count).max_depenetration_velocitycaps how fast a contact pushes the body out of an overlap: Rapier's soft contacts correct position at the world'snormalized_max_corrective_velocity(3 m/s) without touching velocity, and the per-body cap clamps eachSolverContact.distto at least-cap * dtin themodify_solver_contactshook 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 forceandadd torque(Action::AddForce,Effect::AddForce,Act::AddForce,ACT_ADD_FORCE123 in the logic ABI and 101 in the script ABI, a script'sadd_force/add_torque) pick aForceModeand a vector.d2/d3::apply_forcesreduces 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 fromReadMassProperties) and adds it toExternalImpulse, 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):ContactTrackeris pure core. The runtime'strack_contacts(dim2/dim3, inFixedPostUpdateafter the writeback) feeds it Rapier's Started/Stopped reports once per fixed tick (sensor pairs areContactKind::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_vmhands 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 intoengine.touchingfor thetouching?reporters), andContactEvent::edgemarks the actor-level transition thatwhen I touchlistens 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 anExitReason; a run reset clears the tracker without delivering.when I touchgainedphase(Enter/Stay/Exit, default Enter) andscope(Any/Collision/ Trigger, default Any) so old documents behave as before; scripts hearEvent::Contact(EVENT_CONTACT21) for every phase. Still open: the shape-replacement and teleport reasons (a replaced collider entity ends its pairs as Separated). - Legacy effects:
world::is_dynamicalso recognizes a simulated dynamic Rigidbody, sopush,set velocityand the like act on planned bodies. The navmesh and fracture still read the legacyBodyonly. - Commands:
set-physics-layer-name,set-layer-collision,physics-plan;physics-checknow 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/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.
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.
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 aControllerService(installed per frame bycontroller::with_service, the way queries are).Moveis a displacement without gravity,Simplea speed with gravity (fall speed builds while airborne and is zeroed on a floor or ceiling). Moves run in order on the spot with apendingdisplacement; the pose is written once per fixed step byblockloom-runtime/src/controller.rs::apply_motion. A move belowmin_move_distanceskips 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.ControllerAccessis folded intoQueryAccess::scope, so a VM tick, compiled logic or a script can move a controller where it already asks queries. Fixed obstacles the controller meets fireEvent::Collision(Enter, Stay, Exit by move); moving bodies are left to the contact lifecycle. - Blocks:
ControllerMove(mode Move or Simple, x y z) andSetController(property, value) are bothAction::Controller { op, vector },Effect::Controller,Act::Controller(ACT_CONTROLLER125 in the logic ABI, 103 in the script ABI) andcontroller::run_op, the one entry every adapter calls.ControllerNumberandControllerTextread(hit index, field)of the last move (grounded,flags,moved x,normal y, ...; seecontroller::read_number). Scripts getmove_controller,simple_move_controller,set_controller,controller_number,controller_textandis_grounded(READ_CONTROLLER55,TEXT_CONTROLLER23).ABI_VERSIONis 40 andLOGIC_ABI_VERSION37.tests/codegen.rsholds 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).
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::planis pure (intent and environment in, displacement and events out) andsettletakes the move's hits back (landing, head hits, slope, sliding, support).motor::driveis the glue that calls the controller service (probe_movefor 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, afterpublish_sensors) copies the Move/Jump/Sprint/Crouch actions (Move P2for the second player) into the intent of every player-owned motor;drive_motorsruns first in the fixed chain, beforecontroller::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 atturn_speed. - Blocks:
MotorAct(steer, jump, let go of jump, sprint/crouch on and off, push, stop) andSetMotorride the controller op channel ("motor <op>"throughcontroller::run_op, so no ABI change);MotorNumber/MotorTextread the motor (grounded,speed,jumps left,jumped,landed, ...). Scripts getmotor_steer,motor_jump,motor_sprint,motor_crouch,motor_push,motor_stop,set_motor,motor_numberandmotor_text. Events are exposed as one-tick reporter flags (jumped,landed,left ground,hit head); there is nowhen I landhat yet. - Input (
input.rs): actions have akind(Button, Axis, Vector2), a four-keycomposite,processors(dead zone, scale, invert, normalize), amapthat can be switched off withset_map_enabled, and aplayer.InputBinding::MouseDeltabinds look.InputConfig::add_player_actionsadds the standard Gameplay map (Move, Look, Jump, Sprint, Crouch, Interact) andplayer_copymakes 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).
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.
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.
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.
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.
Design: docs/plugin-system-and-voxel-plan.md; decisions, measurements and
deviations of the first implementation: docs/plugin-adr-0001.md.
blockloom-plugin-apihas 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) andid.rs.blockloom-coredepends on this crate and never on the host, which has libloading and git and must not reach the wasm build. Bumpversions::PLUGIN_ABIonly whenabi.rschanges.blockloom-plugin-hostis 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,lifecycleandnative(library loader, capability gate).- Data: a plugin's data is an opaque
PluginRecordin the document, eitherActorComponent::Pluginon an actor orProject.plugin_resources. Core never reads a payload, so a missing, newer or broken plugin loses nothing on open and save.ActivePlugins::auditclassifies each record, andcommands::plugins::preflightrefuses Play and Build while one that is noteditor_onlyhas 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, plusadd_plugin_component,set_plugin_component,set_plugin_resource,plugin_inspect(verify a folder and say what it is) andplugin_call. Package changes are transactions onplugins.jsonandplugins.lockonly. A command a plugin declares is run asplugin-id/name key=valuein the shell (Action::Plugin, forwarded asplugin_call) and through the MCPplugin-calltool. A command's action isadd_component,set_field,set_resource,set_resource_fieldormodule.StateDto.pluginsis the snapshot slot. - Builds: a pack that carries plugins is
PACK_VERSION2 (without them it is still written as 1), each plugin'splugin.jsonand shipped files go togame/plugins/<id>/, and Android builds refuse plugins that have code. A web build ships portable ones (the page's mounted files holdplugins/<id>/,Launch::Websends the samePluginsloadout for targetwasm32-unknown-unknown, andblockloom_plugin_host::files::set_readerpoints package reads atvfs); a code plugin with no portable module is refused for web by the ship plan. The desktop player hosts that code:Launch::Playerhands the world anEditorMessage::PluginsbeforeStart, built byplugins::shipped_loadoutfrom the pack'sPackedPlugins throughblockloom_plugin_host::shipped(Package::load_shippedchecks 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::ModulesonOpenProject, keyed by package hash, dropped when the package changes or the project closes): amodulecommand action callsNativeModule::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 theMutex. - Portable modules (
runtime.portable,blockloom-plugin-host/src/portable.rs, contract inblockloom-plugin-api/src/wasm.rs) run a.wasmthrough wasmi with portable dispatch: one capped memory, only theblockloom.log/callimports, andcall_limit_ms * FUEL_PER_MSfuel per call. A call that runs out or traps stops the module andModules::getloads a fresh one next time.Modulesholds aModule::{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 toAction::PluginCalland run by the VM asEffect::PluginCall, whole numbers as JSON integers. The world forwards it asRuntimeMessage::PluginCall, and the editor runs it (commands::plugins::run_block: slots named by the schema, anactorargument filled from the running actor, an actor slot's name resolved to an id, then the sameplugin_callpath). Codegen emits it asAct::PluginCall(slots read intolets in order, then one host act; over the logic ABI asACT_PLUGIN_CALLwith the slots as a JSON array inc, whole numbers as integers, whichlogic.rsturns back into the sameEffect::PluginCall), andpreflightstops 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-blockruns one from the shell and MCP, and the snapshot'splugins.blockslists every available block. The palette draws the statement ones (Blocks.qml's plugin section, grouped by category): the onePluginBlockrow'sheadis a function of the instruction that splits the schema'slabelinto label pieces and value pieces (key: "args",index: i, fieldPluginArg:i), cached so the canvas keeps its controls.headas a function andindexare 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 sendsEditorMessage::Plugins { loadout }betweenLoadandStart(PROTOCOL_VERSION26): per plugin its code runtime, hooks and the blocks whose commands are module ops (ActivePlugins::loadout).world::begin_runopens each module (WorldPlugins::load) and callsworld.startwith the plugin's records and resources;end_runcallsworld.stopand drops them, so a module lives exactly as long as a run. Hooks are named by aStage(Input,PreSimulation,FixedSimulation,EffectApplication,PostPhysicsin the fixed step,RenderExtractionandPresentationper frame), ordered by theirbefore/after, and called once per stage as ophook.<name>with{stage, hook, tick, dt}; a paused world skips the fixed stages. A module answers{"effects": [...]}ofsay,broadcastanderror, which the world applies in order - a plugin never touches the world itself. AnUnsupportedanswer 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 asRuntimeMessage::PluginCall. The runtime'spluginsfeature (default) pulls in the host crate, which builds for wasm32 withoutlibloading(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/tallyanswersworld.stopwith a final tally line. A plugin whose manifest sayseditor.previewis also hosted while nothing plays (plugins::preview, 3D only): the world opens it onLoad,Pluginsand the end of a run, starts it withpreview: trueand draws what it answers, but runs no hooks or blocks and dropssays. 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 callssense::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::readturns 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'svalueas the schema'sreturnstype; a mismatch is an error.WorldPlugins::readremembers 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 isInstructionKind::WhenPlugin { plugin, block, event, args }, compiled toTrigger::Pluginand started byEvent::Plugin { plugin, event, args, actor }, which a module raises with aneventeffect ({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'sext_operator, so the world's installed reader answers it exactly as for the VM), and a hat is anEntrywhosedetailisplugin_detail(plugin, event and slots joined by U+001F), started byfire("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.rsholds statement, reporter and hat against the VM line for line, andLOGIC_ABI_VERSIONis 35. A script reaches the same three through the script ABI (ABI_VERSION38):Actor::plugin_call(plugin, block, &[PluginArg])isACT_PLUGIN_CALL(c= slots as JSON),plugin_number/plugin_textareREAD_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), andEvent::PluginisEVENT_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_blocksreports all three shapes (PluginBlockShape) sopreflightchecks 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 operatorPluginReadhas alayoutandresultthat are functions of the value, and theWhenPluginheader'sheaduses text pieces with anindex(both blockstitch features).plugins/examples/tallyhas "tally of" and "when tally changes" blocks; itsaddraiseschangedonce a run hosts it. - Plugin SDK (
blockloom-plugin-sdk): a plugin author implementsPlugin(start, thencallover bytes orcall_jsonoverserde_json::Value) and names it withexport_plugin!. The macro expands to the C entry symbol on a desktop target and to the portable module's four exports onwasm32-unknown-unknown, so one source builds as acdylibor a.wasmwith no pointers in sight.Hostgiveslogandcall(host services); anErrorcarries theStatusthe host sees and is logged. A panic is caught on native and answersPanicked; on wasm a panic hook logs it before the module traps.NativeModule::from_entrystarts a plugin's entry function in-process, which is how the SDK's tests (blockloom-plugin-sdk/tests) andplugins/examples/tallyrun the real ABI without a library on disk. The tally example's package source isplugins/examples/tally/package;just example-pluginstages and seals it with its wasm intotarget/plugins/. A change toabi.rsorwasm.rsgoes through the SDK too. - Plugin Manager:
PluginManagerDialog.qml, opened from the top bar's plug button. It listsplugin_list/plugin_check/plugin_commandsand runs the sameplugin_*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_listand the state snapshot'splugins.typeslist every component and resource type (name,displayName,kind,version,fields,defaults), built bytypes_jsonfrom the installed schemas. A form has a row per field and aPluginValueEditorper value (bool,int,number,text,color,vec3,choice,asset,actor, andlistof 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.qmldraws aPlugincomponent 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 throughset_plugin_component/set_plugin_resource, so validation and undo are the backend's. QML tests:tests/qml/tst_PluginInspector.qml. A newFieldTypemust be taught toPluginValueEditor.qml(andsrc/plugins.ts). Drawing hints are declarative and never touch a payload: a field'sui(label,widgetofsliderfor a bounded int/number ormultilinefor text,unit,step,visible_whenof{field, equals | not_equals}or truthy) and a component'sinspector.groups(label,fields,collapsed).check_uiinschema.rsrefuses 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 itsvisible_whenfails (the value stays in the record). - Plugin panels (
PluginPanelDialog.qml,Contributions::panels): a package'spanelslist a name, title and items -text,resource(aPluginRecordFormover one of its resources, written throughset_plugin_resource) andcommand(a form over the command's arguments and a button that runsplugin_call).check_definitionrefuses an unknown resource or command. The snapshot'splugins.panelscarries 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'stoolsname acastmodule op, areach, acommand, that command'sargs($hit.cell.0reads the cast's answer,$option.namean option, anything else is text) and typedoptions.check_definitionrefuses an unknown command, argument or option. The snapshot'splugins.toolslists them and the scene view's toolbar gets a toggle per tool in 3D, with the options drawn byPluginValueEditorunder it (kept in the QMLSettingsas JSON per tool).SceneTool::PluginplusSceneView::plugin_tool(PluginToolView: plugin, tool, cast op, reach, outline, drag, option values;PROTOCOL_VERSION28) 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 asRuntimeMessage::PluginTool; the editor resolves the arguments (ToolSchema::resolve_args) and runs the command (commands::plugins::run_tool,plugin-run-toolin the shell and MCP), one undo step, which reloads the world and so redraws the preview. A tool withdragpaints 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 onePluginTool, whichrun_toolruns batched as a single undo step (Escape or losing focus drops it). A tool withoutline(a$-less path into the cast's answer holding[x0 y0 z0 x1 y1 z1], read byschema::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 mostMAX_VERTICES) and reports a bad one as an error.plugins::applyqueues aMeshOponengine.plugins.meshes(a 2D game reports that only 3D can show one) andplugin_meshes::sync(Update, 3D) carries them out: one entity per (plugin, name) withMesh3d, a standard material shared by roughness and emission (vertex colors multiply the base), and a fixed trimesh collider whencollideris set. A same-named mesh replaces the old entity.mesh_visibilitychanges drawing and retains the collider, including on replacement;plugins::endqueuesClear. 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-pluginseals the wasm). Itsworldresource (presetisland|caves|flat|empty, seed, size, voxel size, origin, solid, palette colors/emission) is read atworld.start;grid.rsholds 32-cell sections ofu8material ids with exact logical bounds and full-height 32 by 32 columns (air sections unallocated, a boundary edit dirties the neighbour),terrain.rsgenerates from hashes of each cell's own coordinates (the same seed always gives the same cells, trees and ore included),mesher.rsis the greedy cube mesher (visible faces only, faces across chunk borders ask the grid, each glowing material its own group), andlib.rsanswers every op with the meshes that changed (chunk/x/y/z, and.../glow<id>for emissive ones, whose meshemissionis color times the palette's intensity) plusremove_meshfor ones that went away. Opsset,fill,sphere,generate(blocks and commands), reportersget/height, and rays in world units (ray.rs, a cell-grid walk:castreports the cell, the empty cell before it, the face normal and distance;breakandplaceedit 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=smoothselects the CPU density mesher (smooth.rs): fixed tetrahedra, shared vertices and central-gradient normals across chunk halos. Signed density overrides ingrid.rsare 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 throughsave_voxelsorpersistent: true, using player storage and versioned compressed checkpoints.streamed: trueuses bounded section residency around invoker components orstream_voxels, with horizontal column radius, vertical radius and a dense-cell byte budget; canonical queries and sparse edits survive eviction.gpu_meshing: trueadds GPU vertex generation with CPU collision and fallback meshes.fracture_voxelsdetaches unsupported six-connected regions into editable convex bodies, retaining cells when fragment budgets are exhausted. See the voxel milestone indocs/plugin-adr-0001.mdfor settings, limits and validation. Render jobs stay in bounded 16-cell tiles; checkpoint v2 migrates v1 pages by cell coordinate.lod.rscaches 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_sampleinspects them in level lattice coordinates.lod_mesh.rsbuilds 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_meshpolls them until ready; these mesh data have no collision and do not replace full-detail renderer meshes yet.voxel_lod_selectpreviews 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. Optionalvisual_loduses presentation camera snapshots to stage a complete visual cut before replacing the previous one. Fine meshes are hidden throughmesh_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.rsclips 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 throughplugin_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.rstraverses 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. Seedocs/voxel-lod-research.mdfor the LOD implementation stages. Its manifest asks foreditor.preview,project-storageandgpu-compute; preview does not load or write player saves. Saved edits are theworldresource'seditslines (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 atworld.start(a bad line is reported by number and skipped), so the scene view and every run show them;add_voxel_editappends one andclear_voxel_editsforgets them, both through theset_resource_fieldcommand action (sets one resource field, or withappendpushesvalueonto its list; one undo step; atemplatebuilds the value from the command's arguments,sphere {x} {y} {z} {radius} {material}, so novalueis needed).paint_voxel,paint_voxel_box,paint_voxel_sphereandpaint_voxel_shapeare 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 (Shapeinshape.rs, a sparse table ingrid.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 fromShape::faces, with faces on a cell's edge hidden by a whole-cube neighbour,shapeis the op,shape_voxelthe command and block (ten blocks now),shape X Y Z namea 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. Theinfinitepreset (0.3.x) streams edgeless terrain: editablenoiselayers shape the land,biomeslines carve temperature/humidity rectangles with their own surface and hills, andfeatureslines 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_distantwarms far tiles in budgeted slices.pages_per_ticklimits nearest-first page work per call; portable infinite cube worlds process at most one 8-cell mesh tile, skipping empty tiles.chunk_workersdoes 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. Theblockslist is the block registry: one JSON object per line (assets/blocks/*.jsonimport 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,MeshDatacarries 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 listimporters(name, extensions,limit_ms) andbuildhooks (name,limit_ms). Both are module ops over raw bytes,importer.<name>andbuild.<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, withwarnings,errorsanddependencies). 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 mostMAX_FILES/MAX_OUTPUT_BYTES, and a portable module gets the schema'slimit_msof fuel for the one call (CodeModule::call_bytes). An import ofassets/x.gplwritesassets/x.gpl.imported/...(never imported again) and records source, output and dependency hashes in.blockloom/imports.json, soimports::statussaysfresh,source_changed,dependency_changed,output_missing,output_editedorsource_missing; a re-import replaces exactly what the last one wrote, and deleting the source in the tray forgets it.import_assetsruns the first importer that takes a file's extension (a failure is logged, the copy stays);plugin-importers,plugin-importsandplugin-import path=... importer=...are the shell/MCP side. Before a desktop or web buildplugins::run_build_hooksruns every hook with the target and the asset list, stages the files under.blockloom/cooked/, andBuildOptions::extras(ExtraFile) copies them togame/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-reimportimports 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/paletteimports GIMP.gplpalettes as a one-rowpalette.png(stored-deflate PNG, no compressor) pluspalette.json, and itscookhook fails a build that has an unimported palette. - HTTP registries (
blockloom-plugin-host/src/registry.rs): aplugins.jsonregistry value that starts withhttps://(orhttp://for localhost, 127.0.0.1 and ::1) is anHttpRegistry, which reads a published registry folder (index.json,archives/) from any static host throughureq(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 listeditor.modules(.qmlfiles, which need thetrusted-editorcapability 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'scontent_hash; an update changes the hash and asks again.plugin_trustandplugin_untrustexist in dispatch for the window only: they have no shell or MCP command and the attach socket refuses them (GUI_ONLY). The snapshot'splugins.editorModuleslists each such plugin withtrusted,changedand its modules, and gives a module'sfileonly while trusted. The top bar's app-window button opens the dialog, which asks for trust, lists the modules and loads one into aLoaderwithhostas an initial property:host.plugin,host.project,host.resource(name),host.setResource(name, payload),host.call(command, args)andhost.app(the editor'sinvokeand state, i.e. host-level).plugins/examples/com.example.notesis the example. QML test:tests/qml/tst_PluginEditors.qml. A package may also listeditor.inspectors({component, module}, one.qmlper component, same capability and trust):InspectorPanel.qml'spluginSectionloads it in place of the schema form once the package is trusted (the form stays otherwise), withhost.write(next),host.call(...),host.payload,host.type,host.appandhost.project.com.example.noteshas astickycomponent 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_librariesis false for both). The runtime.sois built with--features plugins, the plugin host skipsureqthere, builds ship plugins in the APK's assets (the samegame/plugins/<id>/layout) andandroid.rspoints the host's file reader at the APK. Compile-checked foraarch64-linux-android; never run on a device. - Host services (
blockloom-plugin-host/src/services.rs): a module'shost.callreaches oneHostServiceshub, namedarea.verb:host.version,rng.*(hashes of{seed, index}, so a replay matches),storage.*andsave.*(needproject-storage),diag.*,jobs.*, and whatever aServiceProviderchained on adds (the world'sphysics.*andnav.*,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 innative.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).BlobStoreis a flat key space with atomic multi-keycommit;DiskStorewrites through a temp file and a rename,MemoryStoreserves tests. Content blobs live underblobs/<sha256>(put/get) and every write is held toStoreLimits(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: ablob:<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.startrunsjob.<name>in slices under a per-frame budget (WorldPlugins::run_jobs), highest priority first. A slice answers{progress, state},{done, result}or{error};statecomes back next slice, cancel calls the op once withcancel: true, and a job'seventis raised when it ends. A package'snodesare typed graph nodes (blockloom-plugin-api/src/ generation.rs,GraphDef::planchecks types, one source per input and cycles); thegraph.evaluatejob 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|markerplus the host's own call counts, timings, errors and faults per plugin.plugins::reportsends them from the world about every fifth status report (RuntimeMessage::PluginDiagnostics, andplugin/*render metrics for the profiler);StateDto.plugin_diagnostics,plugin-diagnosticsin the shell and the Plugin Manager's rows read them. - Editor surfaces (
blockloom-plugin-api/src/surfaces.rs): a package'smenus,shortcutsandoverlays. 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 andMain.qml'sInstantiatorofShortcuts callplugin_call. An overlay is a scene-view toggle that asks the hosted preview module foroverlay.<name>and draws the answeredShapes as gizmos (SceneView::plugin_overlays,PROTOCOL_VERSION29); overlays only look.surfaces_jsonincommands/plugins.rsbuilds the snapshot.conflictsin a manifest, two packages providing one service and hook orders that cannot be met areActivePlugins::conflicts(); a blocking one stops Play and Build and is listed byplugin-checkand the manager. - Rendering service (
blockloom-plugin-api/src/rendering.rs): aninstanceseffect 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 aconvex_hulloraabbcollider as well as a trimesh. A package'sshadersare.weslfiles registered asblockloom::plugin_<id>_<name>(rendering::module_name): core'sshader_libkeeps 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'spasses::sync_plugin_shadersadds each as a Shader asset when the loadout's shaders change. - GPU compute (
blockloom-plugin-api/src/compute.rs, theblockloom-plugin-gpucrate,blockloom-runtime/src/plugin_compute.rs; ADR 0001's twenty-third batch): a plugin never holds the device. A package'skernels(.wgsl, an entry, a workgroup size, named group-0 bindingsread|read_write|uniform) need thegpu-computecapability, andcheck_kernel(naga, no optional capabilities) refuses a kernel whose bindings differ from its schema or whose loops aren'tfors over a constant-bounded counter (total iterations withinMAX_STATIC_COST), at seal, install and load. A module answersgpu_buffer,gpu_write,gpu_dispatch,gpu_readandgpu_freeeffects (Effect::gpu_command->GpuCommand; no capability is an error), a read comes back as the module'sgpu.resultop.ComputeEngine(featureengine, wgpu) owns the buffers and queues per plugin; each frame it runs commands in order in one encoder underFRAME_INVOCATIONS, round-robin, writes through a staging copy so they stay ordered, and reads back through mapped staging buffers. The runtime'sComputeLinkcarries commands to the render world (runinRenderSystems::Cleanup, on Bevy's device) and reports back;Loadout::kernelscarries the kernels,PluginHost::kernels_serialsays when they changed, and a run's end clears buffers. The SDK'sgpumodule builds the effects. Tests:cargo test -p blockloom-plugin-gpu --features engine -- --include-ignoredwithVK_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 answersworld.save, is replaced and getsworld.restore; a native change needs the run restarted (ReloadReport::restart_needed). WithBLOCKLOOM_PLUGIN_ISOLATION=processa native library is loaded by theblockloom-plugin-workerbinary beside the editor (BLOCKLOOM_PLUGIN_WORKERnames another;allhosts portable modules there too):isolated.rsspeaks 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.rsisblockloom-plugin-sdk/templates/lib.rs, which the SDK'stests/scaffold.rscompiles and runs), schemas,build.sh(wasm build plusplugin-seal) and a CI workflow. A native one is the same crate plus a native library: itsbuild.shalso builds this machine'scdyliband runsplugin-add-native path=... target=... library=...(scaffold::add_native: records the library under its target triple, makes the tiernative, adds thenative-executioncapability and seals again), so each platform's library is one more run of it. The SDK'stestingfeature addsHarnessandharness!(T)(harness!(T, [Capability::ProjectStorage])): the plugin runs in-process through the host's loader with memory stores.docs/plugin-api.mdis the author guide. CI runscargo test -p blockloom-plugin-sdk --features testingand the host withtest-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.healthis the sealed proof package;plugins/examples/tallyis the SDK one, with code, andplugins/examples/palettethe importer/build-hook one.
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.
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.
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.
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.
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.
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/*.
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.
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/*.
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/*.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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/*.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
- 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. vm::compileflattens each actor's canvas into aVec<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 inVariables, and pure operators over constants fold away, so evaluating one hashes nothing.- Simulation runs on Bevy's
FixedUpdate: a constant-rate step (FixedMaincatches up whatever the display does) that pulls the project'sworld.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-frameUpdatepublishes the sensor snapshot (sense::publish), turns input and rapier contacts intovm::Events (at most once a frame, so a few sunk fixed steps never repeat a keypress), and readsVm::tick's results next step.
- set in Project Settings and applied by
- The runtime applies those effects to the ECS - actors made and unmade in
world::apply_lifetimes, shared ones inworld::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 aPhysicsPose/PrevPosepair, andrecord_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 amove/glidesprite from the pose its step's effects pushed it to, both just as smooth as a rolling ball.restore_posesputs 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.
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.
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.
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.
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.
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 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).
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.
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.
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.
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.
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.
- The interface has three global themes plus per-element overrides and
scrollable UI lists. Variables persist only when a
save variableblock 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 throughset-localeand 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.rsfrom the text layout's glyph geometry (parley, viaComputedTextBlock::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 ownallowandmax 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/detachdid 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
stdand a linker for it. The web player is staged withjust web-player, and web builds need the wasm target'sstdfor 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.
sayshows 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
waitinside 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.
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.