Skip to content

Behaviors and phases

Behaviors define reusable runtime rules.

behavior HeatedLiquidFlow for Liquid + LbmD3Q7 + Thermal {
update when self.density > 0.02 {
let evaporation = clamp(
self.density * (0.0004 + self.temperature * 0.0010),
0.0,
self.f0
);
set self.density = clamp(self.density - evaporation, 0.0, 1.0);
set self.f0 = clamp(self.f0 - evaporation, 0.0, 1.0);
}
}

The for clause declares the required model capabilities:

behavior Name for PropertyA + PropertyB {
...
}

The behavior may run on any model that contains those properties.

A scheduled run is valid only when its concrete model composes every property in the behavior signature. Behaviors never target a model directly.

Model-specific scheduling happens in a phase with run Behavior on Model.

Behaviors declare non-model runtime dependencies with needs.

behavior SunDrying for MoistureCarrier
needs inputs(sun_intensity: Float)
{
update when exposed[+Z] {
set self.moisture = clamp(self.moisture - sun_intensity * 0.035, 0.0, 1.0);
}
}

Input contract syntax:

needs inputs(wind_speed: Float, sun_intensity: Float)
needs inputs(wind_speed: Float [0.0 1.0] ~1e-3)

Input names and types are mandatory inside needs inputs(...). Bounds and precision are optional.

The simulation provides these input dependencies through its in declarations. A required input must exist and have the exact scalar type. When the behavior specifies bounds, the simulation input range must be contained in that range. When it specifies precision, the simulation input must be at least as precise.

Neighborhood dependencies are inferred from using Name in neighbor queries and expansion operations. VonNeumann is always available; other names must be declared in the resolved project. There is no separate neighborhood requirement clause. Each use is checked by the compiler. Directions are spatial operators and are validated where they are used; they are not declared as separate names through needs.

Coordinate directions are written with axis signs:

+X
-X
+Y
-Y
+Z
-Z

Neighborhoods define reachable offsets. They do not define direction names:

neighborhood SmokeRise {
offset [0, 0, 1];
}

Inside behavior and visual bodies:

  • self.field reads the current cell.
  • set self.field = expr; writes the current cell’s next state.
  • set target.field = expr; overrides fields on a created or transformed cell.
  • n.field reads a neighbor binding.
  • Bare identifiers are locals, inputs, functions, or artifact names.

Current-cell fields should not be accessed as bare identifiers.

Unqualified field access is valid only when exactly one property in the current model contains that field. If multiple properties contain the same field name, qualify the access with the property:

self.Plume.heat
self.HeatSource.heat
target.Plume.heat

Neighbor selectors may name either a concrete model or a property:

neighbors[Fire]
neighbors[Thermal]
neighbors[Fire, +X].temperature else 0.0
neighbors[Thermal, +X].Thermal.temperature else 0.0

neighbors[Fire] selects cells of that model. neighbors[Thermal] selects cells of every model that composes Thermal. A selector name that matches both a model and a property is rejected as ambiguous.

Update writes commit atomically. Every right-hand side reads the old state, not the result of previous writes in the same update block.

let bindings inside an operation body are local rule computations: they are evaluated once for the current rule context and can be reused by every following set. They must appear before the first set, and local names may not be duplicated.

behavior PlumeDissipate for Plume {
update {
let density_loss = self.dissipation - self.heat * 0.012;
let cooling = self.dissipation * 0.65;
set self.density = clamp(self.density - density_loss, 0.0, 1.0);
set self.heat = clamp(self.heat - cooling, 0.0, 1.0);
}
}
behavior FireBurnout for Combustible + HeatSource {
transform into Ash
when self.fuel <= 0.05 || self.HeatSource.heat <= 0.02 {
set target.amount = clamp(1.0 - self.fuel, 0.0, 1.0);
}
}

transform into Ash constructs an Ash from its model definition, then applies the target overrides in the transform body.

If no overrides are needed, the body can be omitted:

transform into Ash
when self.fuel <= 0.05;
behavior FireSmokeTrail for Combustible + HeatSource
{
expand with Smoke using SmokeRise toward +Z
when self.HeatSource.heat > 0.30 && self.fuel > 0.02 {
set target.density = clamp(self.HeatSource.heat * 0.62, 0.0, 1.0);
set target.Plume.heat = clamp(self.HeatSource.heat * 0.35, 0.0, 1.0);
}
}

expand creates a target cell while preserving the source cell.

Plain expand only creates a cell when the destination is empty. If the destination is occupied, the operation does nothing.

When expand uses a named neighborhood, the compiler resolves that name directly. Omitting using still selects the world’s default neighborhood.

If no target overrides are needed, the body can be omitted:

expand with Smoke using SmokeRise toward +Z
when self.HeatSource.heat > 0.30;

Use expand replace when the expansion should atomically replace the cell at the destination:

behavior FireSpread for Combustible + HeatSource {
expand replace with Fire toward +X
when self.HeatSource.heat > 0.60 {
set target.HeatSource.heat = clamp(self.HeatSource.heat * 0.75, 0.0, 1.0);
}
}

expand replace is distinct from scheduling a separate expand and delete. It expresses one atomic operation: create the target cell by consuming the destination cell.

behavior ResidueCleanup for Residue {
delete
when self.amount <= 0.01;
}
behavior LbmD3Q7Stream for Liquid + LbmD3Q7 {
stream self.fE toward +X
when self.fE > 0.01
on block reflect into self.fW;
}

stream transfers a nonnegative field quantity directionally through the lattice. The destination accepts only what fits within its field bounds; unaccepted quantity stays at the source. Competing transfers consume capacity in rule order, then source coordinate order. Received quantity cannot be streamed again in the same phase.

An empty destination becomes a cell of the source model with zeroed fields. A blocked transfer can keep its quantity, explicitly delete it, or reflect it into another source field. Reflection also respects capacity. Negative quantities are rejected; floating-point conservation is subject to rounding.

Phases define execution order.

phase exchange {
run ThermalExchange on Fire;
run ThermalExchange on Grass;
run ThermalExchange on Water;
}

run Behavior on Model schedules a behavior for a concrete model. Importing a behavior does not run it.

Phase order is semantic order. Runs execute in assembled source order, and later writes win when two scheduled rules write the same destination. Rules in one phase read the same pre-phase state. Inside a single update block, writes still commit atomically; a later phase sees the previous phase’s committed state.

For competing ordinary expansions, the first eligible operation wins because later operations see an occupied destination. For replacing expansions, the last eligible operation wins. Source cells are visited in lexicographic x/y/z order and expansion offsets retain declaration order. Deleted or transformed sources cannot perform later effects as their old model; newly created cells begin acting next phase.

Hyle Astro