Skip to content

Action

The action block interprets brain actuator outputs and translates them into physical changes in the simulation. It runs at step 4 of the tick loop, after the brain fires. This is where throttle becomes acceleration, braking force becomes deceleration, and movement sweeps trigger entity interactions.

The action block is the inverse of perception: perception turns the world into brain inputs, action turns brain outputs into world effects. Together they form the agent-world interface, completely defined in .quale.

action OperatorActions {
let dt = world.tick
-- Emergency stop overrides everything
when actuator.emergency_stop > 0.8 {
agent.accel = -5.0
agent.stress += 0.05
}
-- Normal braking
when actuator.brake > 0.3 and actuator.emergency_stop <= 0.8 {
agent.accel = -2.0
}
-- Acceleration
when actuator.accelerate > 0.3
and actuator.brake <= 0.3
and actuator.emergency_stop <= 0.8 {
agent.accel = 1.5
}
-- Coasting (no inputs)
when actuator.accelerate <= 0.3
and actuator.brake <= 0.3
and actuator.emergency_stop <= 0.8 {
agent.accel = -0.1
agent.idle_ticks += 1
}
-- Physics integration
agent.speed = max(0, agent.speed + agent.accel * dt)
-- Speed limit enforcement
let zone = speed_zone_at(agent.position)
let limit_ms = zone / 3.6
when agent.speed > limit_ms {
agent.stress += 0.01 * dt
}
-- Position update
agent.position += agent.speed * dt / 1000.0
-- End of route check
when agent.position >= world.length {
agent.reached_end = true
agent.alive = false
}
agent.elapsed += dt
agent.ticks_alive += 1
}

Brain actuator values are read via actuator.<name>. The values are raw floats from the brain’s output nodes. Thresholds declared in the body block are available for comparison but not automatically applied - the action block decides how to interpret each output.

-- Human Factors: compare against body-declared thresholds
when actuator.emergency_stop > 0.8 { ... }
when actuator.brake > 0.3 { ... }
when actuator.accelerate > 0.3 { ... }
-- Network Security: read block decision
when actuator.block > 0.5 { ... }
-- Survival: read directional actuator sub-nodes
let moved = actuator.move_n > 0.5
or actuator.move_e > 0.5
or actuator.move_s > 0.5
or actuator.move_w > 0.5

All actuator.* references must match actuator declarations in the body block. Directional actuators expand to per-direction sub-nodes (e.g., actuator.move_n, actuator.move_e, actuator.move_s, actuator.move_w).


Physics runs when the agent is active. Typical structure: emergency handling, braking, throttle, coasting, then movement.

The Human Factors demo shows a full physics pipeline with prioritized actuator interpretation:

-- Emergency stop overrides everything
when actuator.emergency_stop > 0.8 {
agent.accel = -5.0
agent.stress += 0.05
}
-- Normal braking
when actuator.brake > 0.3 and actuator.emergency_stop <= 0.8 {
agent.accel = -2.0
}
-- Acceleration
when actuator.accelerate > 0.3
and actuator.brake <= 0.3
and actuator.emergency_stop <= 0.8 {
agent.accel = 1.5
}
-- Coasting (no inputs)
when actuator.accelerate <= 0.3
and actuator.brake <= 0.3
and actuator.emergency_stop <= 0.8 {
agent.accel = -0.1
agent.idle_ticks += 1
}
-- Physics integration
agent.speed = max(0, agent.speed + agent.accel * dt)
-- Position update
agent.position += agent.speed * dt / 1000.0

The Network Security demo advances at a fixed rate - the brain does not control movement. All decision-making happens through entity on_cross handlers.

-- Move along the traffic stream (constant scan rate)
agent.position += 0.001 * dt

The Survival demo does not have explicit movement logic in the action block. Grid movement for directional actuators is handled by the engine. The action block only tracks whether the agent moved:

let moved = actuator.move_n > 0.5
or actuator.move_e > 0.5
or actuator.move_s > 0.5
or actuator.move_w > 0.5
when not moved {
agent.idle_ticks += 1
}

During the position update (agent.position += ...), the engine detects entities whose positions fall within the sweep from previous position to new position. For each crossed entity, the appropriate handler fires:

  • on_cross - always fires when the entity is crossed
  • on_enter - fires if within threshold AND below max_speed
  • on_pass - fires if the entity is crossed but on_enter conditions are not met

Inside interaction handlers, agent.position is set to the entity’s position (the crossing point), not the pre-sweep or post-sweep position.


Local variables reduce repetition and improve readability. They are stack-allocated temporaries scoped to the action block. See the let keyword reference for full documentation.

-- Human Factors: bind the tick duration
let dt = world.tick
-- Human Factors: query and compute derived values
let zone = speed_zone_at(agent.position)
let limit_ms = zone / 3.6
-- Survival: compound boolean expression
let moved = actuator.move_n > 0.5
or actuator.move_e > 0.5
or actuator.move_s > 0.5
or actuator.move_w > 0.5

Multiple when blocks at the same nesting level are evaluated independently - they do NOT form an if-else chain. Both when actuator.emergency_stop > 0.8 and when actuator.brake > 0.3 can fire in the same tick if both conditions are true.

The Human Factors demo prevents overlapping actuator effects by adding explicit exclusion conditions:

-- Emergency stop overrides everything
when actuator.emergency_stop > 0.8 {
agent.accel = -5.0
agent.stress += 0.05
}
-- Normal braking (only when NOT emergency stopping)
when actuator.brake > 0.3 and actuator.emergency_stop <= 0.8 {
agent.accel = -2.0
}
-- Acceleration (only when NOT braking or emergency stopping)
when actuator.accelerate > 0.3
and actuator.brake <= 0.3
and actuator.emergency_stop <= 0.8 {
agent.accel = 1.5
}

For mutually exclusive branches, use when / else when / else:

when aspect >= 0.9 {
agent.stress -= 0.02
} else when aspect >= 0.7 {
agent.stress += 0.02
} else when aspect >= 0.4 {
agent.stress += 0.1
} else {
agent.stress += 0.3
}

Rule of thumb: Use standalone when blocks for independent checks. Use when/else when/else for mutually exclusive conditions.


Action blocks typically include end-of-simulation checks that kill the agent when the scenario’s spatial boundary is reached:

-- Human Factors
when agent.position >= world.length {
agent.reached_end = true
agent.alive = false
}
-- Network Security
when agent.position >= world.length {
agent.alive = false
}

The action block has access to the full expression language. See the expression reference for the complete operator and function listing.

FeatureSyntaxDescription
Local bindinglet x = exprStack-allocated temporary
Actuator readactuator.XRead brain output value
Agent stateagent.XRead/write agent state
World stateworld.XRead world constants and state
Spatial querynearest_ahead(type, pos)Query entities ahead
Conditionalwhen cond { effects }Guarded block
Single-line conditionalwhen cond: effectGuarded single effect
Exclusive chainwhen/else when/elseMutually exclusive branches
Ternarycond ? a : bInline conditional
Matchmatch { when cond: val ... }Piecewise function
Math built-insmin, max, absStandard math
Logical operatorsand, or, notBoolean logic

  • Every actuator.X reference must match an actuator X declaration in the body block
  • All agent.* writes must match state declarations in the body block
  • Spatial queries must match query declarations in the world block
  • World state references (world.X) are read-only in action

All statement types available in the action block.

StatementSyntaxDescription
letlet x = exprLocal variable binding, scoped to the action block
when (block)when cond { stmts }Independent conditional guard
when (single-line)when cond: stmtSingle-line conditional
when/else when/elsewhen cond { } else when cond { } else { }Mutually exclusive branches
Assignment (=)agent.X = exprDirect assignment to agent state
Assignment (+=)agent.X += exprAdd and assign
Assignment (-=)agent.X -= exprSubtract and assign
Assignment (*=)agent.X *= exprMultiply and assign
Assignment (/=)agent.X /= exprDivide and assign
recordrecord type { fields }Emit a typed event record
NamespaceAccessDescription
agent.*Read/WriteAll agent state declared in the body block
world.*Read-onlyWorld constants and state (world.tick, world.length, world.max_speed)
actuator.*Read-onlyBrain output values from the current tick
Spatial queriesRead-onlynearest_ahead(), speed_zone_at(), etc.
let bindingsRead-onlyLocal variables declared in the same action block

Independent when blocks vs exclusive chains:

-- INDEPENDENT: both can fire in the same tick
when actuator.brake > 0.3 {
agent.accel = -2.0
}
when agent.speed > limit_ms {
agent.stress += 0.01 * dt
}
-- EXCLUSIVE: only the first matching branch fires
when actuator.emergency_stop > 0.8 {
agent.accel = -5.0
} else when actuator.brake > 0.3 {
agent.accel = -2.0
} else {
agent.accel = -0.1
}

When agent.position changes in the action block, the engine detects entity crossings and fires handlers. This happens during the position update, not after the action block completes.

HandlerConditionFires when
on_crossPosition sweep crosses entityAlways on crossing
on_enterWithin threshold AND below max_speedControlled stop
on_passPosition sweep crosses entity but on_enter not metMissed stop