Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Fluid authoring

Ordinary fluids use the public block-definition and asset path described here. Water and the bundled molten example share this implementation. See the verification audit (docs/water-captures/fluid-framework/acceptance.md) for native, browser, rendering, recovery and performance evidence, including known limits.

Source definition and texture pair

Use ModApp::add_block to publish the source RON, and include its images in the mod’s normal asset overlay. The bundled flagship:molten registration and full RON example (game/flagship-game/assets/blocks/molten.ron) demonstrate both steps. Referenced reaction blocks and peer fluids must also be registered before the registry is finalized. Rebuild both client and server when definitions change; the registry handshake rejects mismatched behavior or materials.

Declare a fluid on its source block using the ordinary mod block-loading path:

Block(
    name: "example:fluid",
    render_pass: Translucent,
    fluid: Some((
        update_interval_ticks: 20,
        flow: (renewal: None),
        appearance: (
            tint: (255, 180, 100),
            surface_opacity: Some(1.0),
            animation_speed_percent: 50,
            depth_tint: (1.0, 1.0, 1.0),
            reflectivity: 0.1,
        ),
    )),
    textures: Fluid(
        still: "example:block/fluid_still",
        flowing: "example:block/fluid_flow",
    ),
)

The loader appends the canonical source/level/falling variants automatically. Do not author or expose all generated states as individual inventory entries. The source is passable and has empty collision; neither is_solid: true nor opaque rendering is valid for a fluid definition. Visual opacity is separate from solid occupancy; surface_opacity: Some(1.0) produces a fully opaque surface while retaining fluid mechanics and the translucent draw pass.

The texture addresses resolve through the same asset overlay as other blocks: example:block/fluid_still selects example/textures/block/fluid_still.png in the overlay (an asset archive places it under assets/). The second address resolves in the same way. All generated variants share the two atlas images; they do not allocate separate images for each state.

Still horizontal surfaces use still. Horizontal surfaces with a computed current, falling surfaces, and vertical sides use flowing. Selection follows the shared local current calculation, so a source can use the flowing image at an outflow. Both near and far meshers use this rule. It does not depend on how fast the fluid simulation runs. The separate appearance rate controls texture frame playback, current-directed scrolling, surface ripples and falling streaks. Horizontal currents scroll along their local eight-way direction; falling currents scroll downward on vertical faces. Still surfaces play their frames without directional drift. World-aligned projection keeps texture phase continuous across split quads and chunk/LOD origins.

textures: All("example:block/fluid") remains valid when both surfaces should share an image. Existing TopBottomSides/PerFace definitions remain accepted: flowing horizontal surfaces use their +X side image. Ordinary blocks retain normal per-face selection. Legacy water_state declarations also accept the Fluid shorthand.

Both pair addresses must be nonempty. Using Fluid textures on a block with neither a fluid definition nor a legacy water state fails loading with the source path and reason. Missing/undecodable image assets follow the existing atlas behavior: log a warning and use the missing-texture placeholder.

Images are resampled to 32×32 per frame with independent mipmaps. A fluid PNG whose height is an integer multiple of its width is a vertical strip of square frames, ordered top to bottom. All frames play in a four-second loop at 100% animation speed, with linear blending between adjacent frames (including last to first). A single square image remains supported. Authored alpha and the existing #tint=RRGGBB transform apply to every frame. Inventory icons show frame zero. Nonfluid textures show their first frame unless the block opts in with texture_animation (see the emissive-mask section of lighting engine quality closure (docs/lighting-engine-quality-closure.md)).

A strip supports up to 64 frames. Larger strips log an asset error and use the existing missing-texture placeholder. Frames share contiguous atlas layers across all generated states. Animation respects the portable 256-layer atlas budget, reserving static slots for the other materials first; a strip that cannot fit logs its address and uses frame zero. The source image and fluid behavior still load. No sidecar .mcmeta timing/order is interpreted.

At 100%, directional texture motion travels one tile per four seconds along each active world axis. Diagonal octants therefore move along both axes; this is visual advection, independent of propagation cadence and entity current strength. animation_speed_percent: 0 freezes frame playback, scrolling and procedural motion together. Fifty percent doubles loop duration. The bundled 32-frame water image now plays in full instead of displaying only its first frame.

Frames and mipmaps are uploaded once during atlas creation (at boot, and again when a joining client’s session content brings a new atlas, below). The renderer chooses and blends layers on the GPU using its existing visual clock; this adds no fluid ticks, mesh rebuilds, per-frame texture uploads or new storage-buffer binding. Animated strips use two texture samples per fluid fragment; single-image fluids and ordinary blocks use one. See animation verification (docs/water-captures/fluid-animation/README.md).

Propagation, renewal and replacement

Settings inside fluid are optional and reject unknown fields.

SettingDefaultBehavior and validation
update_interval_ticks10Integer 1–2400; propagation interval at the authoritative 40 Hz tick rate.
flow.max_spread7Maximum accumulated horizontal level loss, integer 1–7. At attenuation 1, this is horizontal reach on a level floor.
flow.attenuation1Level loss per horizontal step, integer 1–7; a fall resets the loss. If it exceeds max spread, horizontal propagation stops.
flow.downwardtruePermits falls and prefers downward openings. False gives horizontal-only propagation.
flow.renewalSome(())None disables new sources; Some((neighbors:2,support:SolidOrSource)) is the water default.
flow.renewal.neighbors2Required face-adjacent same-fluid sources on the same level, integer 1–4.
flow.renewal.supportSolidOrSourceBlocking floor or full same-fluid source below. Solid requires blocking floor; None permits midair renewal.
replaces[]At most 256 unique, nonempty ordinary block names the fluid may occupy. Air is always eligible. Unknown/missing blocks and other fluid states are rejected.
reactions[]At most 64 declarative rules, described below.

The engine uses one bounded fair work queue for all fluids in a world space. Cadence controls eligibility, not an unlimited per-fluid tick allowance. Each batch evaluates a snapshot before applying changes, preventing a newly placed source from cascading through an entire hillside in one tick. Settled fluid sleeps until neighboring edits or chunk arrival wake local work. Unloaded cells are unknown boundaries, not empty space. Fractional surface geometry and local current rules are shared; definitions do not install a separate slope solver.

There is no viscosity setting. For a thick-fluid preset, explicitly combine a longer update interval, greater attenuation, lower swim speed, and greater drag. Animation speed and current strength remain independent. This describes gameplay settings, not pressure, volume, density, pooling, mixing or heat simulation.

Declarative contact reactions

A rule in fluid.reactions has required id, with, and result names:

reactions: [(
    id: "cool-source",
    with: "flagship:water",
    self_state: Source,
    other_state: Any,
    result: "flagship:deepslate",
    priority: 10,
)],

with names the other fluid’s source, not a generated variant. result must resolve to an ordinary block or air; another fluid or the unknown placeholder is invalid. Same-fluid reactions are rejected. Rule IDs must be unique within a fluid, nonempty and at most 128 UTF-8 bytes. Missing peer/result names fail loading with the owner/rule context instead of silently disabling the interaction.

Both state selectors default to Any. Source includes authored fractional or current sources; Flowing means any non-source, including falling; Falling selects only falling states. Reactions check the six face neighbors, not diagonals. A winning rule replaces only its owner’s cell, leaving the peer untouched.

priority is a signed 16-bit integer, default 0. Higher priority wins; ties use ascending owner source name, then ascending rule ID. Both sides consult this same ordering, including reciprocal rules processed in different batches. Registration order and queue budget do not decide a pair’s winner. Reactions run on scheduled fluid work and wake the existing local neighbors; there are no script callbacks or world-wide contact scans. Unknown neighboring cells defer affected work.

Surface appearance

All settings live in fluid.appearance, are optional, and apply to every state of that fluid in both near and far rendering.

SettingDefaultBehavior and validation
tint(255,255,255)Three byte channels; RGB multipliers for the lit fluid body and foam.
surface_opacityNoneNone uses PNG alpha with depth/reflection adjustments and the legacy 0.95 cap. Some(value) sets final surface alpha exactly; finite 0–1, including fully transparent and fully opaque.
animation_speed_percent100Integer 0–1600. 100 is normal speed, 50 is half speed, 0 freezes texture frames, current-directed scrolling, ripples and falling streaks. Does not change propagation or entity current strength.
depth_tint(0.35,0.65,0.85)Three finite linear RGB multipliers in 0–1. Controls the surface body’s depth absorption; (1.0,1.0,1.0) preserves its authored color at depth.
reflectivity1.0Finite 0–1 multiplier for sky reflections and sun glints; zero disables them.

RON fixed-size triples use parentheses. Unknown settings and out-of-range or nonfinite values fail block loading with the definition’s path and reason. Settings participate in the shared registry handshake fingerprint. Fluid occupancy/save names are unchanged by appearance settings.

Appearance follows the fluid identity even when two fluids reference the same PNG. The atlas shares images across variants with identical materials, and allocates distinct material layers for different appearances or emission. Missing image assets still display the placeholder, retaining the requested material. Native and browser production atlas builders both supply registry metadata.

The renderer uploads 48 bytes of material metadata per atlas layer once at startup. A sampled metadata texture is shared by near/far passes, adding no storage-buffer binding or per-cell simulation work.

A client joining a server whose module set holds modules it does not bake (Enki B issue 40, phase 3) builds the session’s atlas from that set before any world data applies, and the native renderer swaps it in with Renderer::replace_block_textures: a new block texture array, material table, bloom weights and sun-tint flags, the terrain and block-icon bind groups rebuilt over them, and the previous icons released (the host re-bakes them; IconRegistry::replace_gpu keeps each icon id). Layers are assigned by the atlas build in material order, so an address keeps its pixels but not necessarily its layer number; the session’s TextureMap is the remap, and no mesh of the session’s world is built against the boot map. The browser builds its renderer after the handshake accepts, so it builds the session’s atlas directly. GPU test: manifold-render block_texture_swap. The visual clock wraps at 6,400 seconds, which joins continuously for every supported integer percentage rate; it remains independent of the 40 Hz fluid scheduler.

Light emission and light attenuation use the source block’s existing light_emission: (r,g,b) and light_attenuation fields. Emission channels use 0–15 and are inherited by generated states. Both near and distant fluid surfaces retain this material-owned glow independently of ambient and propagated light. The far renderer reads emission from the atlas material table because precise fluid quads use their packed emission word for surface geometry. Surface opacity does not make a fluid physically solid or automatically change its light transport.

These settings describe surface shading. Camera-inside appearance is configured separately below. The complete bundled example and its validation are linked below; the verification audit records the measured performance limits.

Player movement

fluid.movement controls the shared authoritative/predicted player integrator. It applies in Walk mode; Fly mode ignores immersion. This does not automatically add physics to other entities that have no movement system.

SettingDefaultBehavior and validation
can_swimtrueAllows held rise/descent and the collision-checked low-bank exit. False retains immersion slowdown, drag, buoyancy and currents but disables these swimming inputs.
swim_speed2.5Horizontal speed at/above the immersion threshold, in blocks/second; finite 0–30. Existing sprint multipliers still apply.
vertical_speed2.8Requested rise/descent speed, blocks/second; finite 0–30.
immersion_threshold0.4Body-volume fraction at which the immersed movement response replaces walking/gravity; finite 0.01–1.
drag8.0Vertical response rate per second above that threshold; finite 0.01–100. Implicit drag approaches the requested swim/buoyancy/current target stably. Horizontal resistance uses the speed settings.
buoyancy_speed-0.3Idle vertical target, blocks/second; finite -30–30. Negative sinks, zero suspends, positive rises. This is a gameplay response, not a density/pressure solver.
shallow_slowdown0.4Below the threshold, walking speed multiplier is 1 - shallow_slowdown * immersion; finite 0–1.
current_strength1.0Multiplies the local current, including falling current; finite 0–16. Zero disables carrying entities without changing flow or texture animation.

Body queries intersect at most 64 cells and use only loaded voxel storage. A normal player overlaps at most 12. Dry cells require one lookup each. Local surface/current reads treat other fluids as occupied boundaries. Unknown cells contribute no contact and cannot supply a current outlet; queries never load chunks, mutate blocks, or wake fluid work.

When a body touches multiple fluids, the greatest intersected volume selects movement settings; equal volumes use ascending source names. Total immersion and volume-weighted currents include every touched fluid, with each current scaled by its own strength. This keeps mixed-contact decisions independent of registration order without blending conflicting swimming policies.

Future contact integration

manifold_world::fluid::query::sample_body_with_contacts is the small read-only integration seam. It accepts a block lookup, registry and body bounds, returns the aggregate movement contact, and calls a supplied visitor once per touched fluid, in source-name order. Each FluidContact carries a runtime fluid ID, body-volume immersion fraction and scaled current. There are no per-cell effect callbacks, background contact scans, health fields or status-effect dependencies.

An entity system that needs effects can query on its authoritative simulation tick and compare the returned identities with that entity’s previous contact set: new identities are entry, retained identities are ongoing, and removed identities are exit. Store the latest contact with each identity if exit behavior needs its last immersion/current. Keep this state owned by the interested entity system; ordinary movement does not allocate or dispatch effect events. Clear it when changing world/registry, and resolve IDs to source names through registry.fluid when crossing a persistent or mod-facing boundary. Runtime IDs are not save IDs.

Invalid body boxes return None before reads/callbacks; consumers should skip that sample rather than interpreting it as an exit. Missing chunks contribute no contact, so authoritative effects should sample loaded actors instead of using client streaming gaps as gameplay events.

This is a Rust engine integration seam, not a new guest-WASM callback ABI. When health/status gameplay exists, its existing mod system can expose the needed contact data through its normal resource/component bridge and dispatch entry/ongoing/exit there. Fluid definitions and the fluid scheduler will not need an effect-system dependency. No currently exposed definition setting silently promises automatic damage, status effects or script callback dispatch.

Underwater appearance and light

fluid.underwater defaults to Some(()). Set None to keep ordinary atmospheric rendering while inside this fluid. Within Some(...), absorption is a linear RGB triple of extinction coefficients per block, default (0.12,0.065,0.035); each channel must be finite and in 0–16. scatter is the linear RGB scattering color, default (0.025,0.14,0.18), with finite channels in 0–1. Larger absorption shortens visibility; zero leaves that channel unattenuated. Scattering scales with visible scene illumination so an unlit cave stays dark. For example:

underwater: Some((
    absorption: (0.02, 0.20, 0.40),
    scatter: (0.80, 0.20, 0.03),
)),

Both native and browser hosts select the fluid containing the camera and query at most 64 loaded cells up its own vertical column. Its exposed top retains the local fractional height; a different fluid terminates the column. Missing or dry camera cells clear the effect immediately. Entering a different fluid swaps material parameters together with depth; disabling underwater fog or emerging restores atmospheric rendering without retaining the previous tint.

This uses the existing post-process pass and a local column/opaque-depth path approximation capped at 64 blocks, including a shallow-surface fade. It does not trace exact fluid volumes along every view ray; long horizontal views through multiple fluids or an air exit can therefore approximate the wrong path length. These controls do not alter surface opacity or voxel light propagation. Authored emission and attenuation continue through the ordinary block lighting system.

Creative inventory and tool targeting

The client cursor system now queries registered fluids through raycast_fluid_chunk_snapshot. The compatibility raycast_water_chunk_snapshot name and existing water-target bridge retain the same result. The ordinary building ray passes through fluids to the bed; the fluid ray stops at the first fluid before terrain and also supports an immersed origin. Unknown chunks occlude the fluid ray. Its local cell bounds use the shared fractional-height convention (4/9 for level four), and only the same fluid above fills the cell. It is a local cell-volume pick, not a triangle-perfect sloped-surface intersection.

The platform exposes read-only manifold:fluid_catalog (FluidCatalogView in manifold-server-api) on both client and server. source_by_block is indexed by raw BlockId: None means ordinary block; Some(source_block_id) identifies every state of that fluid. source_for handles out-of-range IDs, and is_internal identifies flow/falling variants. This separate resource preserves the existing BlockCatalogView wire layout. Runtime fluid IDs are not exposed or persisted.

The flagship creative inventory uses this metadata to hide internal variants, retain real block IDs after filtering, and expose each source once. Its selected tool chooses the fluid ray for any registered fluid source. Ordinary blocks are never hidden merely because their names resemble historical water variants. Other game mods can declare a resource read and use the same source mapping.

Shipping example: molten

The complete molten definition (game/flagship-game/assets/blocks/molten.ron) is registered with ordinary ModApp::add_block in the flagship game. It reuses bundled glowstone art as a prototype still/flowing image; it does not require new engine code or a special runtime identity. It is available beside water in the creative catalog (Tab), and either source can be dragged to a hotbar slot.

Molten advances every 30 simulation ticks (0.75 seconds at 40 Hz). Its maximum level loss is six and each horizontal step loses two, yielding three blocks of reach on a flat floor. Water’s defaults advance every ten ticks and reach seven blocks. Molten has no source renewal; it remains passable with final opacity 1, orange emission (15,6,1), slower visual animation, and stronger movement drag. There is no contact damage or temperature simulation.

Direct face contact with any water state converts a molten source to deepslate, and molten flowing/falling states to stone. Each reaction replaces only the molten cell. Water is preserved. A source that cools before its downstream flow contacts water can instead cause that unsupported flow to retract normally. Diagonal adjacency alone is not a reaction.

Water now declares fluid: Some(()) itself. Its explicit historical state names and IDs remain for save compatibility; the source and legacy variants resolve the same historical tinted water image for both texture roles. Newly declared fluids automatically inherit their source’s texture pair and material settings.

Fresh isolated native play (run from the water worktree):

VOXEL_WORLD="fluid-example-$(date +%Y%m%d-%H%M%S)" bash tools/water/play.sh

Fresh worlds with registered fluids now enable simulation automatically on both native and browser hosts. The helper also requests the separate development pack/world and, when given a terrain bake, its authored river network. Existing worlds retain their saved activation policy. For repeatable disposable browser acceptance after the canonical threaded build:

bash crates/manifold-host-web/e2e/build.sh
SP_WATER_HEADED=1 node crates/manifold-host-web/e2e/assert-fluid-example.mjs

SP_FLUID_DEFAULT=1 tests ordinary world.redb creation without ?water=1. SP_FLUID_PORT and SP_FLUID_OUTPUT override the test port and evidence folder. The test uses a fresh Playwright storage context and does not alter user saves.

World activation and save compatibility

Ordinary new native saves automatically create the fluid work journal when their registry contains at least one fluid. Browser singleplayer does the same for an empty OPFS file, atomically with its initial world metadata. The browser checks file size before database initialization: backfilling missing metadata in an old save is not mistaken for creating a new one. New in-memory browser fallback worlds also enable fluids. No registry fluids means no fluid work queue.

Existing enabled saves restore their journal independently of launch flags. Existing static saves stay static, even when reopened through the ordinary new default. This avoids spontaneously waking water already placed in old terrain. Native MANIFOLD_WATER=1 retains explicit guarded opt-in for empty saves and requests the authored river network for a newly imported terrain bake. It fails for a populated static save instead of rewriting it. MANIFOLD_WATER=0 can create a static diagnostic world; it never disables an existing journal.

Browser ?water=1 retains the independent world-water-v1.redb development save; ordinary launch uses world.redb. ?water=0 can create a static compatibility fixture in world.redb; on reopen it cannot turn off an enabled journal. Never use these switches as a save-conversion or journal-reset mechanism. Use a new world name or disposable browser storage context for testing.

Chunk palettes persist block names and journals persist world-space positions, not runtime fluid indices. Keep source names and generated names stable when updating a mod. All existing explicit water names and IDs are retained. Removing or renaming mod content still requires the platform’s normal block compatibility handling; the fluid framework does not silently substitute a different fluid. Native recovery coverage saves the shipping water/molten definitions mid-flow, rejects an invalid atomic transaction without advancing chunks or journal, retries, reopens with reversed declarations, and loads the saved chunks one side at a time. Both independent streams resume and retract; a stable source contact cools to the same result. The public example also has clean OPFS reopen coverage.

Pending positions survive saves, but exact cadence deadlines do not: cadence restarts with the restored runtime. Chunks that are unavailable defer dependent local decisions; unrelated loaded cells can continue. Consequently an interrupted or differently streamed transient reaction scene can leave different deposits than an uninterrupted run. Precedence is deterministic for a given contact, not a guarantee of identical contact history across different loading/timing schedules. No elapsed offline time is simulated. This is a source-and-level world simulation, not deterministic replay of wall-clock history.