alife_script.kt
src/jvmMain/kotlin/afield/simulation/agents/alife_script.kt · 2200 lines
package and imports — 95 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("ALife")
package afield.simulation.agents
import afield.data.collections.get
import afield.data.graph.Motion
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.geometry.mesh.rounded
import afield.geometry.mesh.scaled
import afield.geometry.mesh.summary
import afield.graphics.render.circle
import afield.graphics.render.rangeTo
import afield.graphics.render.rect
import afield.graphics.render.thickness
import afield.math.autodiff.clamp
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.floor
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.functions.behaviour
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.statistics.noise
import afield.math.statistics.random
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.media.image.close
import afield.media.image.split
import afield.runtime.execution.cell
import afield.runtime.execution.end
import afield.runtime.execution.go
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.study.ui.numbers
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.data.collections.times
import afield.document.properties.clear
import afield.document.workspace.path
import afield.geometry.mesh.centroid
import afield.geometry.spatial.closest
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.text.distanceTo
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.abs
import afield.math.autodiff.atan2
import afield.math.autodiff.round
import afield.math.autodiff.sign
import afield.math.autodiff.times
import afield.math.functions.atan2
import afield.math.linalg.d
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.isNaN
import afield.math.linalg.times
import afield.math.linalg.x
import afield.math.units.times
import afield.media.image.open
import afield.runtime.execution.start
import afield.simulation.automata.step
import afield.ui.output.h
import afield.geometry.path.FLine
import afield.math.linalg.Vector2d
import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.startCoroutine
import kotlin.coroutines.suspendCoroutine
import afield.media.image.Img
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.random.Random
Per-agent scripted behaviour for ALife: a coroutine each agent runs as its own little program, on a fixed-step virtual clock, composing into weighted steering drives.
See alife.kt for the family overview, and alife_agents.kt for the family this completes.
Why this exists
Everything in alife_agents.kt is a global rule. A flock is BoidFlock.step(p: BoidParams) —
one function, one parameter block, applied identically to every agent on every tick. That is the
right shape for the three canonical emergence demonstrations and it is the wrong shape for the
moment you want this agent to spend two seconds approaching, half a second feeding and then go
somewhere else. A global rule has no place to keep "where am I up to". You end up encoding the
agent's position in its own program as an integer state field and a when, which is the standard
way this is done and is also why it is never done twice the same way.
A coroutine has a place to keep it: the continuation. So a behaviour here reads as the sequence it is —
forever {
phase("travel") { moveUntil({ near(site, 6.0) }, maxSeconds = 8.0) { seek(site); separate() } }
phase("feed") { rest(0.4) }
}
— and the scheduler, not the author, remembers that this agent is 1.3 seconds into its approach.
Why the scheduler counts ticks
The obvious way to build this is on kotlinx.coroutines with delay and a real dispatcher. That
gives you something you can watch and nothing you can assert, because the interleaving depends on
the machine. This scheduler is mine and it counts ticks:
wait(seconds)rounds up to a whole number of steps,ceil(seconds / fixedStep), minimum one. Wake-ups are compared asLongtick numbers, so there is no floating-point boundary to be on the wrong side of, andwait(0.5)at 60 Hz is exactly 30 steps, every time.- Resumptions run from a FIFO queue I own, ordered by
(wakeTick, sequence)with a unique monotonic sequence number, which is a total order. Two behaviours that wake on the same tick resume in the order they went to sleep. - Drives are held in a
LinkedHashMapkeyed by a monotonicLong, so they are evaluated in the order they were registered. (Keying a motor map byAny()— the natural thing, since the key is only a handle — makes the evaluation order depend on identity hash codes, i.e. on the run.) - Every drive reads a pre-step snapshot of every agent, so the result does not depend on which agent is integrated first.
- Randomness is per agent, from
Random(seed, id), never from a shared stream, so adding an agent does not reshuffle the others.
The consequence is ScriptWorld.state: run a population twice from one seed and the DoubleArray
is equal bit for bit. That is asserted, not asserted-about — see AlifeScriptGallery.
Composing, not duplicating
The steering verbs in BehaviourSteering are the Reynolds vocabulary already in this family:
BehaviourSteering.separate, BehaviourSteering.align and BehaviourSteering.cohere are the
three boid terms, so a scripted population can be a flock when its script says to be one, and
ScriptWorld.asBoidFlock hands the state to polarization, nearestNeighborDistances and the
rest of alife.kt's measures rather than growing a second set. Trails come out as FLines
(ScriptWorld.trailFLines) because geometry is FLine's job; nothing here rasterises anything
except where a contact sheet needs pixels.
What it deliberately is not
Not a behaviour tree and not a blackboard. Both are ways of giving a stateless evaluator somewhere to put the state, and the whole argument above is that a coroutine already has somewhere. There is no reactive re-evaluation either: a behaviour that wants to abandon what it is doing says so, with BehaviourScope.moveUntil or BehaviourScope.timeout or by cancelling a BehaviourJob.
Errors and identifiers
class BehaviourCancelled internal constructor(val reason: String) : RuntimeException(reason)
Thrown into a behaviour at its next suspension point when its BehaviourJob is cancelled — by BehaviourJob.cancel, by its parent being cancelled, or by the agent dying.
Caught and swallowed by the scheduler, so try/finally in a behaviour runs (which is how
BehaviourScope.moveFor guarantees it takes its drives back out of the motor set) without the
cancellation being reported as a failure. Catching it yourself and carrying on is a mistake: the
next wait throws it again.
data class ScriptEvent
Something a behaviour did, stamped with the tick it happened on — the run's audit trail.
Phase entries and exits, BehaviourScope.log calls, deaths and behaviour failures all land here. Deterministic like everything else, so ScriptWorld.eventSignature is a second, symbolic witness to reproducibility beside the numeric ScriptWorld.state: two runs can agree on every coordinate and still have made different decisions if the drives happened to cancel out.
data class ScriptEvent(
/** The step it was recorded on; 0 means "before the first step". */
val tick: Long,
/** The agent it belongs to, or -1 for a world-level event. */
val actorId: Int,
/** A short stable tag — `phase`, `phase-end`, `log`, `died`, `failed`. */
val kind: String,
/** The phase name, message or exception text. */
val detail: String
) {
override fun toString(): String
override fun toString(): String = "t$tick #$actorId $kind ${if (detail.isEmpty()) "" else detail}".trim()
}
Drives, senses and targets
fun interface ScriptDrive
A steering ingredient: given an agent and what it can sense, the velocity it would like to have.
Return a desired velocity, not an acceleration — the same convention as the three terms inside BoidFlock.step, and the reason several drives can be blended by weight without their magnitudes fighting. ScriptSense.speed is the speed the active behaviour asked for; scale by it rather than by a constant, so one drive works at any pace.
Must return a finite vector. A drive that throws, or returns a NaN, is reported as a behaviour failure for that agent and contributes nothing; it does not take the run down.
Do not use / on a vector you were handed. Vector2d.div is the one operator on the generated
linalg types that writes its result back into the receiver, so delta / delta.length() silently
mutates delta. Build a fresh Vector2d(x, y) from doubles, as every drive here does.
fun interface ScriptDrive {
fun steer(self: ScriptActor, sense: ScriptSense): Vector2d
The desired velocity for self, in world units per second.
data class WeightedDrive(val drive: ScriptDrive, val weight: Double)
One ScriptDrive and the weight it carries in the blend.
Weights are relative: the motor blend is a weighted mean of the desired velocities, so doubling every weight changes nothing and there is no global gain to tune.
init
init {
require(weight.isFinite() && weight > 0.0) { "drive weight must be finite and > 0, got $weight" }
}
}
internal class MotorSet(val speed: Double, val drives: List<WeightedDrive>)
One BehaviourScope.moveFor's worth of drives, at the speed that behaviour asked for.
class ScriptSense internal constructor
What an agent can see this step — always the pre-step snapshot, never the half-integrated present.
This is what makes the population order-independent. Integrate agent 0 before agent 1 and let
agent 1 read agent 0's new position, and the result depends on the iteration order of a collection;
with everyone reading the same snapshot it does not, and a separate() between two agents is
symmetric instead of giving whichever went first the advantage.
class ScriptSense internal constructor(
val world: ScriptWorld,
val self: ScriptActor,
/** The speed the currently-evaluating behaviour asked for — scale desired velocities by this. */
val speed: Double
) {
val position: Vector2d get() = Vector2d(self.sx, self.sy)
self's position as at the start of this step.
val velocity: Vector2d get() = Vector2d(self.svx, self.svy)
self's velocity as at the start of this step.
fun neighbours(range: Double): List<ScriptActor> = world.neighbours(self, range)
Live agents other than self within range of it, under the world's metric, in agent-id order.
O(N) per call and therefore O(N²) per step, the same trade BoidFlock.step makes and for
the same reason: a few hundred scripted agents is the scale this is for, and legible neighbour
code is worth more here than bucket arithmetic.
fun delta(x: Double, y: Double): Vector2d
Offset from self to (x, y), taking the shorter way round if the world wraps.
fun delta(x: Double, y: Double): Vector2d =
Vector2d(world.deltaX(self.sx, x), world.deltaY(self.sy, y))
fun canSee(other: ScriptActor): Boolean = world.canSee(self, other)
Whether nothing blocks the straight line from self to other — both read off the pre-step snapshot, so it agrees with the positions every other query in this class reports.
True in a world with no walls, which is the useful default rather than a special case: a
script written with canSee runs unchanged in an empty arena.
fun canSee(x: Double, y: Double): Boolean = world.lineOfSight(self.sx, self.sy, x, y)
Whether nothing blocks the straight line from self to (x, y).
A point query with no radius — a sight line, not a corridor. For "could I fit through
there" ask ScriptWorld.wallField's clearPath with the agent's radius.
fun visibleNeighbours(range: Double): List<ScriptActor>
Live agents other than self within range that self can also see — neighbours with the
wall between them removed.
Costs a segment-crossing test per candidate on top of neighbours's O(N), so it is the
verb for a sensing behaviour rather than for a flocking term applied to hundreds of agents.
fun visibleNeighbours(range: Double): List<ScriptActor> =
if (world.wallField.isEmpty) neighbours(range)
else neighbours(range).filter { world.canSee(self, it) }
}
fun interface ScriptTarget
Somewhere an agent might steer toward — resolved fresh every step, so a target that moves is followed and a target that has gone (a dead agent, a cleared goal) simply stops pulling.
Returning null means "no target now", which every drive treats as contributing nothing rather
than as an error. Build them with BehaviourScope.point, BehaviourScope.actor,
BehaviourScope.nearest and BehaviourScope.centroid.
fun interface ScriptTarget {
fun resolve(world: ScriptWorld, self: ScriptActor): Vector2d?
The position to aim at, or null if there is none this step.
The agent
class ScriptActor internal constructor
One scripted agent: a position, a velocity, a motor set its behaviours write into, and a name for what it is currently doing.
State is kept as plain doubles rather than Vector2ds. Partly that is BoidFlock's convention and
partly it is that the mutating Vector2d.div makes a stored vector a liability — a caller who
writes agent.position / 2.0 would move the agent. position and velocity hand out fresh copies.
Create these with ScriptWorld.spawn; the constructor is internal because an agent that is not in a world has nothing to step it.
class ScriptActor internal constructor(
val world: ScriptWorld,
val id: Int,
x: Double,
y: Double,
/** Hard cap on speed, whatever a behaviour asks for. */
val maxSpeed: Double,
val radius: Double,
/**
* How much of the gap between current and desired velocity is closed per step, in `0..1`. 1 is instant
* turning (correct for a projectile, wrong for anything with inertia); the default 0.25 gives a visible arc
* without the overshoot a spring would.
*/
val steerResponse: Double,
val tag: String,
/**
* What happens when this agent's swept path meets one of the world's obstacles: one of [WallResponse]'s
* three, `SLIDE` by default. Irrelevant in a world with no obstacles, where the integrator does not even
* look.
*/
val wallResponse: WallResponse = WallResponse.SLIDE,
/**
* How much of the normal velocity survives a [WallResponse] `BOUNCE`, in `0..1`. Ignored by the other two
* responses.
*/
val restitution: Double = 0.8
) {
internal var x: Double = x
internal var y: Double = y
internal var vx: Double = 0.0
internal var vy: Double = 0.0
internal var sx: Double = x
The pre-step snapshot every drive reads. See ScriptSense.
internal var sy: Double = y
internal var svx: Double = 0.0
internal var svy: Double = 0.0
var alive: Boolean
False once ScriptWorld has killed it; a dead agent is no longer integrated or sensed.
var alive: Boolean = true
internal set
var phase: String?
The innermost BehaviourScope.phase currently running, or null between phases.
var phase: String? = null
internal set
val rng: Random = Random(world.seed * -0x61c8864680b583ebL + id * 0x2545f4914f6cdd1dL)
This agent's own random stream, seeded from the world seed and the agent id. Per-agent rather than shared, so inserting an agent does not reshuffle every other agent's future.
internal val motors = LinkedHashMap<Long, MotorSet>()
internal var parallelCount: Int = 0
var wallContacts: Int
How many times this agent's swept path has been stopped, deflected or reflected by a wall.
The readout that distinguishes "went round the barrier" from "never met it": an arm of a counterfactual whose agents record zero contacts has not been tested by the geometry at all.
var wallContacts: Int = 0
internal set
internal val trailX = ArrayList<Double>()
internal val trailY = ArrayList<Double>()
internal val phaseStack = ArrayList<String?>()
val position: Vector2d get() = Vector2d(x, y)
A fresh copy of the current position.
val velocity: Vector2d get() = Vector2d(vx, vy)
A fresh copy of the current velocity, in world units per second.
val speed: Double get() = hypot(vx, vy)
Current speed.
val driveCount: Int get() = motors.values.sumOf { it.drives.size }
How many drives are currently registered across all active behaviours — a load readout.
fun distanceTo(px: Double, py: Double): Double
Distance to (px, py) under the world's metric.
fun distanceTo(px: Double, py: Double): Double =
hypot(world.deltaX(x, px), world.deltaY(y, py))
fun distanceTo(p: Vector2d): Double = distanceTo(p.x, p.y)
Distance to p under the world's metric.
fun placeAt(px: Double, py: Double)
Move this agent immediately, ignoring its velocity — teleporting, so use it for placement and respawns, not for motion. The trail records the jump as a jump.
fun placeAt(px: Double, py: Double) {
require(px.isFinite() && py.isFinite()) { "placeAt needs finite coordinates, got ($px, $py)" }
x = px; y = py
world.confine(this)
}
override fun toString(): String
override fun toString(): String =
"ScriptActor(#$id ${if (tag.isEmpty()) "" else "$tag "}at %.1f,%.1f phase=%s)".format(x, y, phase ?: "-")
}
Jobs
class BehaviourJob internal constructor
A running behaviour, and the handle that cancels it.
Jobs form a tree: an agent's program is a root, and every BehaviourScope.parallel inside it is a
child. Cancelling a job cancels its children first, so the inner finally blocks run before the
outer ones and drives come out of the motor set in the order they went in. An agent's death
cancels its root.
Cancellation is cooperative and takes effect at the next suspension point, which for a
behaviour means the next wait — the same contract kotlinx has, for the same reason: there is no
safe place to interrupt arbitrary code. A behaviour with no suspension point in it cannot be
cancelled, which is one of the two things ScriptWorld.maxResumptionsPerStep catches.
class BehaviourJob internal constructor(
internal val world: ScriptWorld,
val actor: ScriptActor,
internal val parent: BehaviourJob?
) {
internal val children = ArrayList<BehaviourJob>()
internal var pending: Sleeper? = null
var cancelled: Boolean
True once cancel has been called, or a parent was cancelled.
var cancelled: Boolean = false
internal set
var done: Boolean
True once the behaviour has returned, thrown, or finished unwinding a cancellation.
var done: Boolean = false
internal set
val isActive: Boolean get() = !cancelled && !done
Still worth waiting for.
fun cancel(reason: String = "cancelled") = world.cancelJob(this, reason)
Cancel this behaviour and everything it started. Idempotent.
override fun toString(): String
override fun toString(): String =
"BehaviourJob(#${actor.id} ${if (cancelled) "cancelled" else if (done) "done" else "active"})"
}
internal class Sleeper
A behaviour parked until wakeTick. seq breaks ties, giving wake-ups a total order.
internal class Sleeper(
val wakeTick: Long,
val seq: Long,
val cont: Continuation<Unit>,
val job: BehaviourJob
)
The world
class ScriptWorld
A fixed-step arena of scripted agents: the clock, the scheduler, the population and the physics.
Drive it yourself — step advances exactly one tick and returns, so a Field box can step, draw,
step, draw, and run is only a loop over step. Nothing here starts a thread, and nothing here
reads the wall clock; time is tick * fixedStep and that is the only clock the agents can see.
val w = ScriptWorld(seed = 7L)
repeat(12) { i -> w.spawn(x = 20.0 + i * 12.0, y = 100.0) { forever { … } } }
w.run(600)
The same seed, the same spawns and the same step count give the same state, bit for bit.
declaration — 51 lines, from line 503
class ScriptWorld(
val width: Double = 200.0,
val height: Double = 200.0,
/**
* Seconds per tick. Also the quantum every [BehaviourScope.wait] is rounded up to, so it sets the time
* resolution of every script in the world.
*/
val fixedStep: Double = 1.0 / 60.0,
val seed: Long = 1L,
/**
* True for a torus (the metric [BoidFlock] uses, and what `asBoidFlock` assumes); false to clamp at the
* walls, which stops agents but does not make them avoid them — that is [BehaviourSteering.avoidEdges]'
* job.
*/
val wrap: Boolean = true,
/**
* The cap [BehaviourScope.parallel] checks. A behaviour that launches a sub-behaviour inside a `forever`
* and never cancels it is the standard way to fill a scheduler with garbage, and it presents as a gradual
* slowdown rather than an error. This turns it into an error.
*/
val maxParallelPerActor: Int = 8,
/**
* How many behaviour resumptions one step may take before the world declares a livelock. Catches the two
* shapes that otherwise hang: a `while (true)` with no `wait`, and a `wait` short enough to round to zero
* (it cannot — `wait` has a one-tick floor — but a hand-rolled `forever` over an empty body would still
* spin).
*/
val maxResumptionsPerStep: Int = 200_000,
/**
* Whether to accumulate per-agent trails for [trailFLines]. On by default because the trail is the picture;
* turn it off for long runs where only the final state matters.
*/
val recordTrails: Boolean = true,
/**
* How many [BehaviourSteering.route] drives may run a fresh A\* in one step. A grid search is the one thing
* in this file that is not `O(1)` per agent, and a population that all repaths on the same tick is the
* frame that drops. Agents over the budget keep steering to the route they already have and try again next
* step; the budget is spent in agent-id order, so which agents get it is reproducible.
*/
val maxRouteSearchesPerStep: Int = 8,
/**
* Cells one A\* may expand before giving up. A search that hits this returns no route rather than a bad
* one, and the drive falls back to a direct seek.
*/
val maxRouteExpansions: Int = 20_000,
/**
* The cap on a [NavGrid]'s cell count. Exceeding it enlarges the cells and sets [NavGrid.coarse] rather
* than allocating a grid proportional to the arena.
*/
val maxNavCells: Int = 40_000
) {
init
init {
require(width > 0.0 && height > 0.0) { "world must be positive, got ${width}x$height" }
require(fixedStep > 0.0 && fixedStep.isFinite()) { "fixedStep must be > 0, got $fixedStep" }
require(maxParallelPerActor >= 1) { "maxParallelPerActor must be >= 1, got $maxParallelPerActor" }
require(maxResumptionsPerStep >= 1) { "maxResumptionsPerStep must be >= 1" }
require(maxRouteSearchesPerStep >= 1) { "maxRouteSearchesPerStep must be >= 1" }
require(maxRouteExpansions >= 16) { "maxRouteExpansions must be >= 16" }
require(maxNavCells >= 64) { "maxNavCells must be >= 64" }
}
private val population = ArrayList<ScriptActor>()
private val roots = ArrayList<BehaviourJob>()
private val runQueue = ArrayDeque<() -> Unit>()
private val sleepers = java.util.PriorityQueue<Sleeper>(
compareBy<Sleeper> { it.wakeTick }.thenBy { it.seq }
)
private val eventLog = ArrayList<ScriptEvent>()
private val failureLog = ArrayList<String>()
private var seq = 0L
private var motorKey = 0L
private var started = false
var tick: Long
Steps taken. 0 before the first step.
var tick: Long = 0L
private set
val time: Double get() = tick * fixedStep
Simulation seconds elapsed: exactly tick * fixedStep, never a wall-clock reading.
val actors: List<ScriptActor> get() = population
The population, in spawn order, alive and dead.
val living: List<ScriptActor> get() = population.filter { it.alive }
Live agents, in spawn order.
val events: List<ScriptEvent> get() = eventLog
Everything the behaviours recorded, in order. See ScriptEvent.
val failures: List<String> get() = failureLog
Drives that threw or returned non-finite vectors, and behaviours that threw.
var lastStepResumptions: Int
How many behaviour resumptions the last step ran — a load readout, and a livelock warning.
var lastStepResumptions: Int = 0
private set
Walls
val wallField: ObstacleField = ObstacleField()
The world's walls. See alife_walls.kt for what they are and why they are segments.
Public so a drive can query it; add through addWall and friends so the navigation cache is invalidated with it.
private val navGrids = LinkedHashMap<Long, NavGrid>()
private var routeBudget = 0
private val settleScratch = DoubleArray(2)
var routeSearches: Long
Total A* searches run since the world began — the cost readout for a routed population.
var routeSearches: Long = 0L
private set
var routeSearchesDeferred: Long
Searches refused this run because maxRouteSearchesPerStep was already spent.
var routeSearchesDeferred: Long = 0L
private set
val obstacles: List<ScriptObstacle> get() = wallField.obstacles
The walls, in the order they were added.
fun addObstacle(o: ScriptObstacle): ScriptObstacle
Add o to the world.
Obstacles need a bounded world. A segment does not wrap, so on a torus the swept test
would be right in the middle of the arena and quietly wrong across the seam — an agent would
pass through a wall that, in the wrapped metric every other part of this class uses, it was
heading straight into. Refusing is better than being wrong in one place. Build the world with
wrap = false.
fun addObstacle(o: ScriptObstacle): ScriptObstacle {
require(!wrap) {
"obstacles need a bounded world: build the ScriptWorld with wrap = false. " +
"A wall segment does not wrap, so a swept test across the seam would be wrong."
}
wallField.add(o)
navGrids.clear()
return o
}
fun addWall(x0: Double, y0: Double, x1: Double, y1: Double, name: String = "wall"): ScriptObstacle
A single straight wall from (x0, y0) to (x1, y1).
fun addWall(x0: Double, y0: Double, x1: Double, y1: Double, name: String = "wall"): ScriptObstacle =
addObstacle(ScriptObstacle(name, doubleArrayOf(x0, y0, x1, y1), closed = false))
fun addPolyline(vararg points: Double, name: String = "polyline"): ScriptObstacle
An open run of points, x, y, x, y, … — a corridor side, a curved barrier.
fun addPolyline(vararg points: Double, name: String = "polyline"): ScriptObstacle =
addObstacle(ScriptObstacle(name, points, closed = false))
fun addPolygon(vararg points: Double, name: String = "polygon"): ScriptObstacle
A closed outline, x, y, x, y, …. Its interior is unreachable, not solid — see the file KDoc.
fun addPolygon(vararg points: Double, name: String = "polygon"): ScriptObstacle =
addObstacle(ScriptObstacle(name, points, closed = true))
fun addRect(x: Double, y: Double, w: Double, h: Double, name: String = "rect"): ScriptObstacle
An axis-aligned box with its lower-left corner at (x, y).
fun addRect(x: Double, y: Double, w: Double, h: Double, name: String = "rect"): ScriptObstacle {
require(w > 0.0 && h > 0.0) { "addRect needs a positive size, got ${w}x$h" }
return addObstacle(
ScriptObstacle(name, doubleArrayOf(x, y, x + w, y, x + w, y + h, x, y + h), closed = true)
)
}
fun addDisc
A round pillar, approximated by segments sides.
fun addDisc(
cx: Double,
cy: Double,
radius: Double,
segments: Int = 24,
name: String = "disc"
): ScriptObstacle =
addObstacle(ScriptObstacle(name, discPoints(cx, cy, radius, segments), closed = true))
fun obstacleFLines(z: Number = 0): List<FLine> = obstacles.map { it.outline(z) }
Every wall's outline, for drawing the world the agents are in.
fun navGrid(inflate: Double): NavGrid
The navigation grid for a circle of inflate, built on first ask and cached.
Cached per quantised radius (to 1e-3), which is what makes a population of like-sized
agents share one grid instead of building a near-identical one each. Adding a wall clears the
cache.
fun navGrid(inflate: Double): NavGrid {
require(inflate > 0.0 && inflate.isFinite()) { "nav inflate must be > 0, got $inflate" }
val key = Math.round(inflate * 1000.0)
return navGrids.getOrPut(key) {
NavGrid.build(width, height, wallField, key / 1000.0, maxNavCells)
}
}
fun findRoute(sx: Double, sy: Double, gx: Double, gy: Double, inflate: Double): DoubleArray?
A route for a circle of inflate from (sx, sy) to (gx, gy) as x, y, x, y, …, or
null if there is no way through.
The whole pipeline: grid, A*, string-pull. Call it directly to plan once; BehaviourSteering
route is this with a cache and a budget in front of it.
fun findRoute(sx: Double, sy: Double, gx: Double, gy: Double, inflate: Double): DoubleArray? {
val grid = navGrid(inflate)
routeSearches++
val cells = grid.route(sx, sy, gx, gy, maxRouteExpansions) ?: return null
if (cells.size == 1) return doubleArrayOf(sx, sy, gx, gy)
return grid.stringPull(cells, wallField, sx, sy, gx, gy)
}
internal fun takeRoutePermit(): Boolean
Spend one of this step's route-search permits, or return false. Called in agent-id order.
internal fun takeRoutePermit(): Boolean {
if (routeBudget <= 0) { routeSearchesDeferred++; return false }
routeBudget--
return true
}
fun lineOfSight(x0: Double, y0: Double, x1: Double, y1: Double): Boolean
Whether a straight sight line from (x0, y0) to (x1, y1) is unobstructed.
fun lineOfSight(x0: Double, y0: Double, x1: Double, y1: Double): Boolean =
wallField.isEmpty || wallField.lineOfSight(x0, y0, x1, y1)
fun canSee(a: ScriptActor, b: ScriptActor): Boolean = lineOfSight(a.sx, a.sy, b.sx, b.sy)
Whether a can see b — both on snapshot positions, so it agrees with what drives read.
Population
fun spawn
Add an agent at (x, y) running program.
The program starts at the next scheduler drain — before the first step if you spawn before stepping, otherwise on the step after. It is not run inline, so spawning from inside a behaviour cannot reorder the caller.
@param vx initial x velocity, world units per second @param vy initial y velocity.
Not cosmetic, and found by measurement: BehaviourSteering.align steers toward the mean
neighbour velocity, so a population spawned at rest has nothing to align to and the
alignment term contributes exactly zero until something else has started the agents moving.
A 90-agent population spawned at rest reached a polarization of only 0.106 after four
seconds of a flocking script that should have ordered it — not because the steering was
wrong but because the ordered state has to be reached from a disordered one, and rest is
not disordered, it is empty. Spawn with random headings to make a self-organisation claim,
the same initial condition BoidFlock.random uses and for the same reason.
@param program the agent's whole life, as a sequence. Returning from it ends the agent's
scripting (it keeps its velocity and its motor set empties, so it coasts to a halt); most
programs are a BehaviourScope.forever.
fun spawn(
x: Double,
y: Double,
maxSpeed: Double = 20.0,
radius: Double = 1.0,
steerResponse: Double = 0.25,
vx: Double = 0.0,
vy: Double = 0.0,
tag: String = "",
wallResponse: WallResponse = WallResponse.SLIDE,
restitution: Double = 0.8,
program: suspend BehaviourScope.() -> Unit
): ScriptActor {
require(x.isFinite() && y.isFinite()) { "spawn needs finite coordinates, got ($x, $y)" }
require(maxSpeed.isFinite() && maxSpeed > 0.0) { "maxSpeed must be > 0, got $maxSpeed" }
require(radius > 0.0 && radius.isFinite()) { "radius must be > 0, got $radius" }
require(vx.isFinite() && vy.isFinite()) { "spawn needs a finite velocity, got ($vx, $vy)" }
require(steerResponse > 0.0 && steerResponse <= 1.0) {
"steerResponse must be in (0, 1], got $steerResponse"
}
require(restitution >= 0.0 && restitution <= 1.0) {
"restitution must be in [0, 1], got $restitution"
}
val a = ScriptActor(
this, population.size, x, y, maxSpeed, radius, steerResponse, tag, wallResponse, restitution
)
val m = hypot(vx, vy)
if (m > maxSpeed) { a.vx = vx / m * maxSpeed; a.vy = vy / m * maxSpeed } else { a.vx = vx; a.vy = vy }
a.svx = a.vx; a.svy = a.vy
confine(a)
population.add(a)
roots.add(launch(a, null, program))
return a
}
fun kill(a: ScriptActor)
Kill a: cancel its behaviours (so their finally blocks empty its motor set), stop
integrating it, and take it out of everyone's neighbour queries.
It stays in actors with its trail intact, because a picture of a run needs the agents that did not make it.
fun kill(a: ScriptActor) {
if (!a.alive) return
a.alive = false
a.vx = 0.0; a.vy = 0.0
record(a.id, "died", "")
roots.firstOrNull { it.actor === a }?.let { cancelJob(it, "died") }
}
fun neighbours(self: ScriptActor, range: Double): List<ScriptActor>
Live agents other than self within range, in agent-id order, on snapshot positions.
fun neighbours(self: ScriptActor, range: Double): List<ScriptActor> {
require(range.isFinite() && range > 0.0) { "range must be > 0, got $range" }
val r2 = range * range
val out = ArrayList<ScriptActor>()
for (o in population) {
if (o === self || !o.alive) continue
val dx = deltaX(self.sx, o.sx)
val dy = deltaY(self.sy, o.sy)
if (dx * dx + dy * dy <= r2) out.add(o)
}
return out
}
Geometry
fun deltaX(from: Double, to: Double): Double
Signed x offset from from to to, the shorter way round if the world wraps.
fun deltaX(from: Double, to: Double): Double =
if (wrap) wrappedDelta(from, to, width) else to - from
fun deltaY(from: Double, to: Double): Double
Signed y offset from from to to, the shorter way round if the world wraps.
fun deltaY(from: Double, to: Double): Double =
if (wrap) wrappedDelta(from, to, height) else to - from
internal fun confine(a: ScriptActor)
Put a back inside the arena: wrap if the world is a torus, otherwise clamp, and then — if
the world has walls — out of any wall it is overlapping.
The second half is depenetration, not collision: collision is ObstacleField.sweep and
happens during the move. This is for the cases where no move happened — an agent placed inside
a pillar by spawn or ScriptActor.placeAt, or one clamped at the arena edge into a wall
that runs along it. Pushing to the nearest surface is the only choice that is a function of
where the agent is; teleporting it to a free cell would be a function of the navigation grid,
which the agent may not have one of.
internal fun confine(a: ScriptActor) {
if (wrap) {
a.x = wrapInto(a.x, width)
a.y = wrapInto(a.y, height)
} else {
if (a.x < 0.0) { a.x = 0.0; if (a.vx < 0.0) a.vx = 0.0 }
if (a.y < 0.0) { a.y = 0.0; if (a.vy < 0.0) a.vy = 0.0 }
if (a.x > width) { a.x = width; if (a.vx > 0.0) a.vx = 0.0 }
if (a.y > height) { a.y = height; if (a.vy > 0.0) a.vy = 0.0 }
if (!wallField.isEmpty) {
if (wallField.pushOut(a.x, a.y, a.radius, settleScratch)) {
a.x = settleScratch[0]
a.y = settleScratch[1]
}
}
}
}
Scheduler
internal fun nextSeq(): Long = seq++
internal fun nextMotorKey(): Long = motorKey++
internal fun record(actorId: Int, kind: String, detail: String)
internal fun record(actorId: Int, kind: String, detail: String) {
eventLog.add(ScriptEvent(tick, actorId, kind, detail))
}
internal fun launch
internal fun launch(
a: ScriptActor,
parent: BehaviourJob?,
block: suspend BehaviourScope.() -> Unit
): BehaviourJob {
val job = BehaviourJob(this, a, parent)
parent?.children?.add(job)
val scope = BehaviourScope(this, a, job)
runQueue.addLast {
if (job.cancelled) {
job.done = true
finish(job)
} else {
block.startCoroutine(scope, object : Continuation<Unit> {
override val context = EmptyCoroutineContext
override fun resumeWith(result: Result<Unit>) {
job.done = true
job.pending = null
val e = result.exceptionOrNull()
if (e != null && e !is BehaviourCancelled) {
failureLog.add("actor ${a.id}: ${e::class.simpleName}: ${e.message}")
record(a.id, "failed", "${e::class.simpleName}: ${e.message}")
}
finish(job)
}
})
}
}
return job
}
Detach a finished job from its parent and give back its parallel slot.
private fun finish(job: BehaviourJob) {
val p = job.parent
if (p != null) {
p.children.remove(job)
job.actor.parallelCount = max(0, job.actor.parallelCount - 1)
}
}
internal fun cancelJob(job: BehaviourJob, reason: String)
internal fun cancelJob(job: BehaviourJob, reason: String) {
if (job.cancelled || job.done) return
job.cancelled = true
// Children first, so inner `finally` blocks run before outer ones.
for (c in ArrayList(job.children)) cancelJob(c, reason)
val s = job.pending
if (s != null) {
job.pending = null
sleepers.remove(s)
runQueue.addLast { s.cont.resumeWithException(BehaviourCancelled(reason)) }
}
}
internal suspend fun sleepTicks(job: BehaviourJob, ticks: Long)
Park job's continuation for ticks whole steps.
internal suspend fun sleepTicks(job: BehaviourJob, ticks: Long) {
require(ticks >= 1) { "a wait must be at least one tick, got $ticks" }
if (job.cancelled) throw BehaviourCancelled("cancelled before waiting")
if (!job.actor.alive) throw BehaviourCancelled("agent is dead")
return suspendCoroutine { cont ->
val s = Sleeper(tick + ticks, nextSeq(), cont, job)
job.pending = s
sleepers.add(s)
}
}
internal fun ticksFor(seconds: Double): Long
Convert seconds to whole ticks, rounding up, with a one-tick floor.
internal fun ticksFor(seconds: Double): Long {
require(seconds.isFinite() && seconds > 0.0) { "a wait must be finite and > 0, got $seconds" }
return max(1L, ceil(seconds / fixedStep - 1e-9).toLong())
}
private fun drain() {
var n = 0
while (runQueue.isNotEmpty()) {
if (++n > maxResumptionsPerStep) {
runQueue.clear()
throw IllegalStateException(
"livelock on tick $tick: $maxResumptionsPerStep behaviour resumptions in one step " +
"without the queue emptying. A behaviour is looping without waiting — every " +
"`forever` body must reach a `wait`, `moveFor` or `rest`."
)
}
runQueue.removeFirst().invoke()
}
lastStepResumptions = n
}
fun start()
Run every spawned program up to its first suspension point, at tick 0. Idempotent.
fun start() {
if (started) return
started = true
snapshot()
drain()
if (recordTrails) for (a in population) { a.trailX.add(a.x); a.trailY.add(a.y) }
}
Stepping
fun step()
Advance one tick: wake what is due, run every behaviour to its next suspension point, then integrate.
That order matters and is the one thing here that is a choice rather than a consequence.
Behaviours run first, so a drive registered on this tick is in force on this tick and a
moveFor(1.0) moves the agent for a full second rather than for a second minus one frame. The
alternative — integrate, then decide — makes every behaviour a tick late, which is invisible in
a picture and shows up as an off-by-one in any test that counts steps to arrival.
fun step() {
start()
tick++
while (true) {
val s = sleepers.peek() ?: break
if (s.wakeTick > tick) break
sleepers.poll()
if (s.job.pending === s) s.job.pending = null
if (s.job.cancelled) continue
runQueue.addLast { s.cont.resume(Unit) }
}
drain()
integrate()
if (recordTrails) for (a in population) { a.trailX.add(a.x); a.trailY.add(a.y) }
}
fun run(steps: Int)
step applied steps times.
body — 63 lines, from line 992
fun run(steps: Int) {
require(steps >= 0) { "steps must be >= 0, got $steps" }
repeat(steps) { step() }
}
private fun snapshot() {
for (a in population) { a.sx = a.x; a.sy = a.y; a.svx = a.vx; a.svy = a.vy }
}
private fun integrate() {
snapshot()
// Refilled before any drive runs, and spent in agent-id order below, so which agents get to
// repath on a busy tick is a property of the scenario rather than of the schedule.
routeBudget = maxRouteSearchesPerStep
for (a in population) {
if (!a.alive) continue
var dx = 0.0
var dy = 0.0
var wsum = 0.0
var asked = 0.0
for ((_, m) in a.motors) {
asked = max(asked, m.speed)
val sense = ScriptSense(this, a, m.speed)
for (wd in m.drives) {
val v = try {
wd.drive.steer(a, sense)
} catch (e: Exception) {
failureLog.add("actor ${a.id} drive threw: ${e::class.simpleName}: ${e.message}")
record(a.id, "failed", "drive ${e::class.simpleName}")
continue
}
if (!v.x.isFinite() || !v.y.isFinite()) {
failureLog.add("actor ${a.id} drive returned non-finite (${v.x}, ${v.y})")
record(a.id, "failed", "drive non-finite")
continue
}
dx += v.x * wd.weight
dy += v.y * wd.weight
wsum += wd.weight
}
}
var desX = 0.0
var desY = 0.0
if (wsum > 1e-12) {
desX = dx / wsum
desY = dy / wsum
val cap = min(a.maxSpeed, if (asked > 0.0) asked else a.maxSpeed)
val m = hypot(desX, desY)
if (m > cap) { desX = desX / m * cap; desY = desY / m * cap }
}
a.vx += (desX - a.vx) * a.steerResponse
a.vy += (desY - a.vy) * a.steerResponse
val s = hypot(a.vx, a.vy)
if (s > a.maxSpeed) { a.vx = a.vx / s * a.maxSpeed; a.vy = a.vy / s * a.maxSpeed }
if (wallField.isEmpty) {
a.x += a.vx * fixedStep
a.y += a.vy * fixedStep
} else {
sweepMove(a)
}
confine(a)
}
}
Move a by one step's worth of its velocity, stopping, sliding or bouncing at the first wall
the swept circle meets — and then at the next one, up to WALL_RESPONSE_ITERATIONS times.
Swept and not discrete, and that is the whole point of the routine: a wall here is a line
segment with no thickness, so "does the new position overlap a wall" is false for every wall
an agent crosses cleanly, at any speed. Solving for the first contact instead means a 600
units/s agent — ten units of displacement in a 1/60 s step — stops at the barrier rather than
appearing on the far side of it. AlifeWallsGallery.figureTunnelling runs exactly that, with
a discrete integrator beside it that fails.
After a contact the agent is placed WALL_SKIN outside the surface rather than on it, so the next iteration's sweep does not start in contact with the wall it has just resolved.
The remaining displacement is dropped if the iteration budget runs out. An agent wedged where three walls meet should stop there; spending the leftover motion unchecked is how a body ends up inside geometry, and it is much harder to see than a body that stalled.
source — 43 lines, from line 1074
private fun sweepMove(a: ScriptActor) {
var px = a.x
var py = a.y
var rx = a.vx * fixedStep
var ry = a.vy * fixedStep
var iteration = 0
while (true) {
if (rx == 0.0 && ry == 0.0) break
val hit = wallField.sweep(px, py, rx, ry, a.radius)
if (hit == null) { px += rx; py += ry; break }
a.wallContacts++
px += rx * hit.t + hit.nx * WALL_SKIN
py += ry * hit.t + hit.ny * WALL_SKIN
val remX = rx * (1.0 - hit.t)
val remY = ry * (1.0 - hit.t)
when (a.wallResponse) {
WallResponse.STOP -> {
a.vx = 0.0; a.vy = 0.0
rx = 0.0; ry = 0.0
}
WallResponse.SLIDE -> {
val dr = remX * hit.nx + remY * hit.ny
rx = remX - hit.nx * dr
ry = remY - hit.ny * dr
val dv = a.vx * hit.nx + a.vy * hit.ny
a.vx -= hit.nx * dv
a.vy -= hit.ny * dv
}
WallResponse.BOUNCE -> {
val k = 1.0 + a.restitution
val dr = remX * hit.nx + remY * hit.ny
rx = remX - hit.nx * dr * k
ry = remY - hit.ny * dr * k
val dv = a.vx * hit.nx + a.vy * hit.ny
a.vx -= hit.nx * dv * k
a.vy -= hit.ny * dv * k
}
}
if (++iteration >= WALL_RESPONSE_ITERATIONS) break
}
a.x = px
a.y = py
}
Reproducibility witnesses
fun state(): DoubleArray
The whole population's state as one array — x, y, vx, vy per agent, in agent-id order.
The determinism claim is asserted on this, with contentEquals, not on a summary. A hash
can collide and a fitness can tie; an array of every coordinate cannot agree by accident.
fun state(): DoubleArray {
val out = DoubleArray(population.size * 4)
for ((i, a) in population.withIndex()) {
out[i * 4] = a.x; out[i * 4 + 1] = a.y
out[i * 4 + 2] = a.vx; out[i * 4 + 3] = a.vy
}
return out
}
fun stateSignature(): String
A short hex digest of state over the raw bits of every coordinate — for printing, where a 4N-element array will not fit.
Raw bits, not rounded values, so it is exactly as strict as contentEquals: two runs whose
digests match agree on every coordinate to the last mantissa bit.
fun stateSignature(): String {
var h = -0x340d631b7bdddcdbL
for (v in state()) {
h = (h xor java.lang.Double.doubleToRawLongBits(v)) * 0x100000001b3L
}
return "%016x".format(h)
}
fun eventSignature(): String
A digest of the event log — the symbolic witness beside the numeric one.
Worth having separately because the two can disagree in both directions. Two runs can land on identical coordinates having taken different decisions (drives that cancelled out), and a run whose phase timeline is identical can still have drifted numerically. A claim of reproducibility should cover both.
fun eventSignature(): String {
var h = -0x61c8864680b583ebL
for (e in eventLog) {
for (c in "${e.tick}|${e.actorId}|${e.kind}|${e.detail}") h = (h xor c.code.toLong()) * 0x100000001b3L
}
return "%016x".format(h)
}
Bridges into the rest of the family
fun asBoidFlock(): BoidFlock
The live population as a BoidFlock, so alife.kt's measures — polarization,
nearestNeighborDistances, BoidFlock.meanSpeed — apply to a scripted run unchanged.
A measurement conversion, one way. The flock's arrays are Float and this world's state is
Double, so stepping the returned flock and reading the result back would quietly lose
precision and break the determinism claim. There is deliberately no way back.
fun asBoidFlock(): BoidFlock {
val live = living
require(live.isNotEmpty()) { "asBoidFlock needs at least one live agent" }
val n = live.size
val x = FloatArray(n); val y = FloatArray(n)
val vx = FloatArray(n); val vy = FloatArray(n)
for (i in 0 until n) {
x[i] = live[i].x.toFloat(); y[i] = live[i].y.toFloat()
vx[i] = live[i].vx.toFloat(); vy[i] = live[i].vy.toFloat()
}
return BoidFlock(width, height, x, y, vx, vy)
}
fun trailFLines(stride: Int = 1, z: Number = 0): List<FLine>
One FLine per agent, its trail so far, split wherever it crossed a wrap seam.
Split rather than drawn through: a torus is a torus, and a line from x = 199 to x = 1 drawn
straight is a stroke right across the picture that no agent ever travelled. The split test is a
step longer than the fastest agent could move in one tick, which is the only thing that
distinguishes a wrap from a fast move.
@param stride keep every nth trail point; 1 is all of them
fun trailFLines(stride: Int = 1, z: Number = 0): List<FLine> {
require(stride >= 1) { "stride must be >= 1, got $stride" }
val out = ArrayList<FLine>()
val jumpX = width * 0.5
val jumpY = height * 0.5
for (a in population) {
var line: FLine? = null
var px = Double.NaN
var py = Double.NaN
var i = 0
while (i < a.trailX.size) {
val cx = a.trailX[i]
val cy = a.trailY[i]
val broke = !px.isNaN() && (kotlin.math.abs(cx - px) > jumpX || kotlin.math.abs(cy - py) > jumpY)
val open = line
if (open == null || broke) {
if (open != null && open.nodes.size > 1) out.add(open)
line = FLine().also { it.moveTo(cx, cy, z) }
} else {
open.lineTo(cx, cy, z)
}
px = cx; py = cy
i += stride
}
line?.let { if (it.nodes.size > 1) out.add(it) }
}
return out
}
fun totalPathLength(): Double
Total distance every agent travelled, summed — the "did anything happen at all" readout.
fun totalPathLength(): Double {
var total = 0.0
for (a in population) {
for (i in 1 until a.trailX.size) {
val dx = deltaX(a.trailX[i - 1], a.trailX[i])
val dy = deltaY(a.trailY[i - 1], a.trailY[i])
total += hypot(dx, dy)
}
}
return total
}
override fun toString(): String
override fun toString(): String =
"ScriptWorld(${population.size} agents on ${width}x$height, tick $tick, " +
"%.2f s, %d events)".format(time, eventLog.size)
}
Wrap v into [0, n).
private fun wrapInto(v: Double, n: Double): Double {
var r = v % n
if (r < 0) r += n
return r
}
The behaviour language
annotation class ALifeBehaviour
Marks the two scripting receivers, so a steering block cannot accidentally call a suspending behaviour verb on the enclosing scope.
Worth the annotation because the mistake it stops is a subtle one rather than a type error. A
steering block is built once, when the behaviour starts; a wait inside it would be a wait
inside the construction of a drive list, which is both meaningless and — without this — compiles.
@DslMarker
annotation class ALifeBehaviour
class BehaviourScope internal constructor
The verbs an agent's program is written in: sequencing, waiting on simulation time, repetition, bounded sub-behaviours, named phases, and motion.
One of these exists per BehaviourJob, so self and world are fixed and a program can be read
top to bottom as one agent's life. Every suspending verb here is a cancellation point.
The suspension discipline, stated once: never sleep a thread in a behaviour. Thread.sleep
blocks the scheduler that is driving every other agent, and this scheduler is not even on a clock
that would notice — time advances in ScriptWorld.step and nowhere else, so a thread sleep is
pure loss. wait is the only way to spend time.
@ALifeBehaviour
class BehaviourScope internal constructor(
val world: ScriptWorld,
val self: ScriptActor,
internal val job: BehaviourJob
) {
val time: Double get() = world.time
Simulation seconds since the run began.
val tick: Long get() = world.tick
Steps taken since the run began.
val position: Vector2d get() = self.position
A fresh copy of this agent's current position.
val velocity: Vector2d get() = self.velocity
A fresh copy of this agent's current velocity.
val phase: String? get() = self.phase
The innermost phase currently running, or null.
val rng: Random get() = self.rng
This agent's own random stream. Per agent, so drawing from it cannot disturb anyone else.
Time
suspend fun wait(seconds: Double) = world.sleepTicks(job, world.ticksFor(seconds))
Spend seconds of simulation time, rounded up to a whole number of steps.
Use this, or waitTicks, and never Thread.sleep — see the class KDoc. The rounding is up and
is the reason the clock is exactly reproducible: at the default 60 Hz, wait(0.5) is 30 steps
and wait(0.001) is 1, always, with no floating-point boundary for a run to land on the wrong
side of.
suspend fun waitTicks(ticks: Int)
Spend exactly ticks steps. The primitive wait is expressed in.
suspend fun waitTicks(ticks: Int) {
require(ticks >= 1) { "waitTicks needs at least 1 tick, got $ticks" }
world.sleepTicks(job, ticks.toLong())
}
suspend fun waitUntil(maxSeconds: Double = 30.0, predicate: () -> Boolean): Boolean {
Wait until predicate holds, testing it once per step, giving up after maxSeconds.
Returns true if it became true, false if it timed out — so the caller decides, rather than the
failure being an exception to forget to catch. predicate is evaluated between steps, on
settled state, and must not suspend or mutate the world.
suspend fun waitUntil(maxSeconds: Double = 30.0, predicate: () -> Boolean): Boolean {
val deadline = world.tick + world.ticksFor(maxSeconds)
while (world.tick < deadline) {
if (predicate()) return true
waitTicks(1)
}
return predicate()
}
Control flow
suspend fun forever(block: suspend BehaviourScope.() -> Unit) {
Repeat block for as long as this behaviour is active and the agent is alive.
If a pass through block spends no simulation time — a body of nothing but synchronous calls —
one tick is spent for it. Without that a forever whose body forgot to move would spin the
scheduler until ScriptWorld.maxResumptionsPerStep fired; with it, the body simply runs at one
pass per tick, which is almost always what was meant.
suspend fun forever(block: suspend BehaviourScope.() -> Unit) {
while (job.isActive && self.alive) {
val began = world.tick
block()
if (world.tick == began) waitTicks(1)
}
}
suspend fun every(seconds: Double, block: suspend BehaviourScope.() -> Unit) {
Run block, then wait seconds, forever. The period is the gap, not the cycle length.
suspend fun every(seconds: Double, block: suspend BehaviourScope.() -> Unit) {
require(seconds.isFinite() && seconds > 0.0) { "every() needs a positive period, got $seconds" }
while (job.isActive && self.alive) {
block()
wait(seconds)
}
}
suspend fun repeatTimes(times: Int, block: suspend BehaviourScope.(Int) -> Unit) {
Run block times times, or until cancelled.
suspend fun repeatTimes(times: Int, block: suspend BehaviourScope.(Int) -> Unit) {
require(times >= 0) { "repeatTimes needs times >= 0, got $times" }
var i = 0
while (i < times && job.isActive && self.alive) {
val began = world.tick
block(i)
if (world.tick == began) waitTicks(1)
i++
}
}
suspend fun parallel(block: suspend BehaviourScope.() -> Unit): BehaviourJob {
Start block as a sub-behaviour of this one and carry on immediately.
The child is owned by this job: it is cancelled when this one is, when the agent dies, and when the returned BehaviourJob is cancelled. It starts at the next scheduler drain, not inline, so launching one cannot reorder the launcher.
Capped at ScriptWorld.maxParallelPerActor per agent, and the cap is a check, not a silent
drop. A parallel inside a forever that is never cancelled is the classic leak here and it
presents as a run that gradually gets slower and stranger rather than as an error.
suspend fun parallel(block: suspend BehaviourScope.() -> Unit): BehaviourJob {
check(self.parallelCount < world.maxParallelPerActor) {
"agent ${self.id} already has ${self.parallelCount} parallel behaviours, " +
"cap is ${world.maxParallelPerActor}. A `parallel` inside a `forever` must be cancelled."
}
self.parallelCount++
return world.launch(self, job, block)
}
suspend fun timeout(seconds: Double, block: suspend BehaviourScope.() -> Unit): Boolean {
Run block with at most seconds to finish in. Returns true if it finished, false if it was
cut off.
Implemented as a child job plus a wait, so an unfinished block is cancelled — its finally
blocks run and its drives come out of the motor set. A timeout that left drives behind would be
worse than no timeout.
suspend fun timeout(seconds: Double, block: suspend BehaviourScope.() -> Unit): Boolean {
val child = parallel(block)
val deadline = world.tick + world.ticksFor(seconds)
while (child.isActive && world.tick < deadline) waitTicks(1)
if (child.isActive) {
child.cancel("timed out after ${"%.2f".format(seconds)} s")
return false
}
return true
}
suspend fun phase(name: String, block: suspend BehaviourScope.() -> Unit) {
Name what the agent is doing for the duration of block, and log it.
Phases nest and the previous one is restored on the way out, including on cancellation, so ScriptActor.phase always names the innermost active behaviour rather than the last one to have started. Both the entry and the exit reach ScriptWorld.events, which makes the phase timeline a thing you can assert on — and it is the readout that shows division of labour, since a population all in one phase at once is a population a single global rule could have produced.
suspend fun phase(name: String, block: suspend BehaviourScope.() -> Unit) {
require(name.isNotBlank()) { "a phase needs a name" }
val previous = self.phase
self.phaseStack.add(previous)
self.phase = name
world.record(self.id, "phase", name)
try {
block()
} finally {
self.phase = self.phaseStack.removeAt(self.phaseStack.size - 1)
world.record(self.id, "phase-end", name)
}
}
fun log(message: String) = world.record(self.id, "log", message)
Append a note to ScriptWorld.events on this tick.
Motion
suspend fun moveFor
Steer by block's drives for seconds, at speed, then stop steering.
The drive set is built once, when the behaviour starts, and registered under a fresh key; it
is removed in a finally, so it goes away on cancellation too. Several moveFors running in
parallel blend: the motor set is the union, and the blend is a weighted mean of the desired
velocities. That is what makes "flee while hugging the wall" a composition rather than a third
special case.
suspend fun moveFor(
seconds: Double,
speed: Double = self.maxSpeed,
block: BehaviourSteering.() -> Unit
) {
require(seconds.isFinite() && seconds > 0.0) { "moveFor needs positive seconds, got $seconds" }
require(speed.isFinite() && speed > 0.0) { "moveFor needs positive speed, got $speed" }
val drives = BehaviourSteering(this).apply(block).build()
val key = world.nextMotorKey()
self.motors[key] = MotorSet(speed, drives)
try {
wait(seconds)
} finally {
self.motors.remove(key)
}
}
suspend fun moveUntil
Steer by block's drives until until holds or maxSeconds runs out. Returns true if until
became true.
The verb most scripts actually want: "go there, and tell me whether you arrived". A plain moveFor has to be tuned to the worst-case travel time and then wastes the difference on every other trip.
suspend fun moveUntil(
maxSeconds: Double = 10.0,
speed: Double = self.maxSpeed,
until: () -> Boolean,
block: BehaviourSteering.() -> Unit
): Boolean {
require(maxSeconds.isFinite() && maxSeconds > 0.0) { "moveUntil needs positive maxSeconds" }
require(speed.isFinite() && speed > 0.0) { "moveUntil needs positive speed, got $speed" }
val drives = BehaviourSteering(this).apply(block).build()
val key = world.nextMotorKey()
self.motors[key] = MotorSet(speed, drives)
try {
return waitUntil(maxSeconds, until)
} finally {
self.motors.remove(key)
}
}
suspend fun coast(seconds: Double) = moveFor(seconds, self.maxSpeed) { keepVelocity() }
Keep the velocity the agent already has, for seconds — ballistic motion.
Needed because an empty motor set means "desired velocity zero", so an agent with no active
moveFor brakes. That is the right default (a script that stops scripting should stop) and it
is wrong for anything thrown, hence this.
suspend fun rest(seconds: Double)
Stop dead and stay stopped for seconds.
suspend fun rest(seconds: Double) {
self.vx = 0.0
self.vy = 0.0
wait(seconds)
}
fun die() = world.kill(self)
Kill this agent: it stops being integrated and stops being sensed, and its behaviours are cancelled.
This returns — cancellation lands at the next suspension point, like every other cancellation
here — so a program that has more to say after dying will say it. Put die() last, or return
after it.
Targets
fun point(p: Vector2d): ScriptTarget
A fixed point. Copied on the way in, so a later mutation of p cannot move the target.
fun point(p: Vector2d): ScriptTarget {
val x = p.x
val y = p.y
return ScriptTarget { _, _ -> Vector2d(x, y) }
}
fun point(x: Double, y: Double): ScriptTarget = ScriptTarget { _, _ -> Vector2d(x, y) }
A fixed point.
fun actor(other: ScriptActor): ScriptTarget
Another agent — resolving to null once it is dead, so the pull simply stops.
fun actor(other: ScriptActor): ScriptTarget {
require(other.world === world) { "agent ${other.id} belongs to a different world" }
return ScriptTarget { _, _ -> if (other.alive) Vector2d(other.sx, other.sy) else null }
}
fun nearest(range: Double = max(world.width, world.height), accept: (ScriptActor) -> Boolean
The nearest live agent within range satisfying accept, re-chosen every step.
Ties break on the lower agent id, because "the nearest" over a set of equidistant candidates is otherwise a coin flip weighted by iteration order, and that is exactly the kind of thing that makes a run irreproducible without looking like it would.
fun nearest(range: Double = max(world.width, world.height), accept: (ScriptActor) -> Boolean = { true }): ScriptTarget =
ScriptTarget { w, me ->
var best: ScriptActor? = null
var bestD = Double.MAX_VALUE
for (o in w.neighbours(me, range)) {
if (!accept(o)) continue
val d = hypot(w.deltaX(me.sx, o.sx), w.deltaY(me.sy, o.sy))
if (d < bestD) { bestD = d; best = o }
}
best?.let { Vector2d(it.sx, it.sy) }
}
fun centroid(accept: (ScriptActor) -> Boolean
The centroid of the live agents satisfying accept, on snapshot positions.
A plain mean of coordinates, so on a wrapping world it is the mean in the unwrapped frame and
a population spread across the seam has a centroid in the middle of the arena rather than on
the seam. That is the honest thing for a cohere-style pull within a cluster and the wrong
thing for a population that is genuinely everywhere; it returns null when nothing matches.
fun centroid(accept: (ScriptActor) -> Boolean = { true }): ScriptTarget = ScriptTarget { w, _ ->
var sx = 0.0
var sy = 0.0
var n = 0
for (o in w.actors) {
if (!o.alive || !accept(o)) continue
sx += o.sx; sy += o.sy; n++
}
if (n == 0) null else Vector2d(sx / n, sy / n)
}
fun near(t: ScriptTarget, distance: Double): Boolean
True when this agent is within distance of t's current position.
fun near(t: ScriptTarget, distance: Double): Boolean {
val p = t.resolve(world, self) ?: return false
return self.distanceTo(p) <= distance
}
fun near(x: Double, y: Double, distance: Double): Boolean = self.distanceTo(x, y) <= distance
True when this agent is within distance of (x, y).
internal fun resolve(t: ScriptTarget): Vector2d? = t.resolve(world, self)
class BehaviourSteering internal constructor(private val scope: BehaviourScope)
The ingredients a BehaviourScope.moveFor blends: each verb adds one weighted ScriptDrive.
Built once per behaviour, not per step — the block runs when the behaviour starts, the drives it produced run every step until the behaviour ends. So capture what should be fixed (a heading locked at the start of a dash) in the block, and read through a ScriptTarget what should track.
Every drive added here is wrapped so that a throw or a non-finite return is logged against the agent and contributes nothing, rather than taking the run down. One bad drive in a population of three hundred should cost you one agent's motion and a line in ScriptWorld.failures.
@ALifeBehaviour
class BehaviourSteering internal constructor(private val scope: BehaviourScope) {
private val drives = ArrayList<WeightedDrive>()
val world: ScriptWorld = scope.world
The world being steered in.
val self: ScriptActor get() = scope.self
The agent being steered.
Exposed because the @ALifeBehaviour DslMarker hides the enclosing BehaviourScope's
self — deliberately, since the mistake it stops is a wait inside a steering block — but
the properties of the agent are exactly what a steering block needs at construction time.
orbit(target, clockwise = self.id % 2 == 0) is the canonical use, and until this existed it
did not compile.
val rng: Random get() = scope.rng
This agent's own random stream — for a choice made once, when the behaviour starts.
val time: Double get() = scope.time
Simulation seconds at the moment the behaviour started, i.e. when this block ran.
internal fun build(): List<WeightedDrive> = drives.toList()
fun drive(weight: Double = 1.0, drive: ScriptDrive)
Add a custom drive. The escape hatch every one of the verbs below is built on.
fun drive(weight: Double = 1.0, drive: ScriptDrive) {
drives.add(WeightedDrive(drive, weight))
}
fun keepVelocity(weight: Double = 1.0) = drive(weight) { _, sense -> sense.velocity }
Keep the velocity the agent already has. See BehaviourScope.coast.
fun stop(weight: Double = 1.0) = drive(weight) { _, _ -> Vector2d(0.0, 0.0) }
Hold still — a desired velocity of zero, which the agent decays toward.
fun direction(heading: Vector2d, weight: Double = 1.0)
Travel along a fixed heading, normalised here so its length does not become a second gain.
fun direction(heading: Vector2d, weight: Double = 1.0) {
val m = hypot(heading.x, heading.y)
require(m > 1e-12) { "direction() needs a non-zero heading" }
val ux = heading.x / m
val uy = heading.y / m
drive(weight) { _, sense -> Vector2d(ux * sense.speed, uy * sense.speed) }
}
fun heading(radians: Double, weight: Double = 1.0)
Travel along the heading radians measures anticlockwise from +x.
fun heading(radians: Double, weight: Double = 1.0) {
require(radians.isFinite()) { "heading must be finite, got $radians" }
drive(weight) { _, sense -> Vector2d(cos(radians) * sense.speed, sin(radians) * sense.speed) }
}
fun seek(t: ScriptTarget, weight: Double = 1.0)
Head straight for t at full speed.
fun seek(t: ScriptTarget, weight: Double = 1.0) = drive(weight) { self, sense ->
val goal = t.resolve(world, self)
if (goal == null) Vector2d(0.0, 0.0) else {
val dx = world.deltaX(self.sx, goal.x)
val dy = world.deltaY(self.sy, goal.y)
val d = hypot(dx, dy)
if (d < 1e-9) Vector2d(0.0, 0.0) else Vector2d(dx / d * sense.speed, dy / d * sense.speed)
}
}
fun arrive(t: ScriptTarget, slowRadius: Double = 12.0, stopRadius: Double = 0.5, weight: Double = 1.0)
Head for t, easing off inside slowRadius and stopping at stopRadius.
The difference from seek is worth a separate verb: a seek at an arriving agent's speed
overshoots and comes back, and a population of overshooting agents jitters around its target in
a way that looks like a bug in the steering rather than the absence of a brake.
fun arrive(t: ScriptTarget, slowRadius: Double = 12.0, stopRadius: Double = 0.5, weight: Double = 1.0) {
require(slowRadius > stopRadius && stopRadius >= 0.0) {
"arrive needs slowRadius > stopRadius >= 0, got $slowRadius and $stopRadius"
}
drive(weight) { self, sense ->
val goal = t.resolve(world, self)
if (goal == null) Vector2d(0.0, 0.0) else {
val dx = world.deltaX(self.sx, goal.x)
val dy = world.deltaY(self.sy, goal.y)
val d = hypot(dx, dy)
when {
d <= stopRadius -> Vector2d(0.0, 0.0)
d >= slowRadius -> Vector2d(dx / d * sense.speed, dy / d * sense.speed)
else -> {
val s = sense.speed * (d - stopRadius) / (slowRadius - stopRadius)
Vector2d(dx / d * s, dy / d * s)
}
}
}
}
}
fun flee(t: ScriptTarget, weight: Double = 1.0)
Head directly away from t.
fun flee(t: ScriptTarget, weight: Double = 1.0) = drive(weight) { self, sense ->
val from = t.resolve(world, self)
if (from == null) Vector2d(0.0, 0.0) else {
val dx = -world.deltaX(self.sx, from.x)
val dy = -world.deltaY(self.sy, from.y)
val d = hypot(dx, dy)
if (d < 1e-9) Vector2d(0.0, 0.0) else Vector2d(dx / d * sense.speed, dy / d * sense.speed)
}
}
fun orbit(t: ScriptTarget, distance: Double = 20.0, clockwise: Boolean = true, weight: Double = 1.0)
Circle t at distance, blending a tangential component with a radial correction.
Which way round is decided by clockwise, and a population that all orbits the same way is a
population that never mixes; pass self.id % 2 == 0 for a script that wants both.
fun orbit(t: ScriptTarget, distance: Double = 20.0, clockwise: Boolean = true, weight: Double = 1.0) {
require(distance.isFinite() && distance > 0.0) { "orbit distance must be > 0, got $distance" }
val sign = if (clockwise) 1.0 else -1.0
drive(weight) { self, sense ->
val goal = t.resolve(world, self)
if (goal == null) Vector2d(0.0, 0.0) else {
val dx = world.deltaX(goal.x, self.sx)
val dy = world.deltaY(goal.y, self.sy)
val d = hypot(dx, dy)
// On top of the target there is no radial direction; pick one from the id so the
// choice is reproducible rather than whatever the arithmetic happened to produce.
val rx = if (d > 1e-9) dx / d else if (self.id % 2 == 0) 1.0 else -1.0
val ry = if (d > 1e-9) dy / d else 0.0
val tx = ry * sign
val ty = -rx * sign
val pull = ((distance - d) / distance * 2.0).coerceIn(-1.5, 1.5)
val cx = tx + rx * pull
val cy = ty + ry * pull
val m = hypot(cx, cy)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(cx / m * sense.speed, cy / m * sense.speed)
}
}
}
fun wander(turn: Double = 2.0, weight: Double = 1.0)
Drift, turning by at most turn radians per second, from this agent's random stream.
A random heading each step is not wander, it is noise: the desired velocities cancel and the agent sits still shaking. This integrates a heading, which is the standard fix and the reason the drive has to carry state of its own between steps.
fun wander(turn: Double = 2.0, weight: Double = 1.0) {
require(turn.isFinite() && turn > 0.0) { "wander turn must be > 0, got $turn" }
var angle = Double.NaN
drive(weight) { self, sense ->
if (angle.isNaN()) {
angle = if (hypot(self.svx, self.svy) > 1e-9) kotlin.math.atan2(self.svy, self.svx)
else self.rng.nextDouble() * 2.0 * Math.PI
}
angle += (self.rng.nextDouble() - 0.5) * 2.0 * turn * world.fixedStep
Vector2d(cos(angle) * sense.speed, sin(angle) * sense.speed)
}
}
fun separate(range: Double = 6.0, weight: Double = 0.8)
Push away from live agents within range, weighted by how close they are — Reynolds'
separation, the same term BoidParams.separationWeight scales inside BoidFlock.step.
fun separate(range: Double = 6.0, weight: Double = 0.8) {
require(range.isFinite() && range > 0.0) { "separate range must be > 0, got $range" }
drive(weight) { self, sense ->
var ax = 0.0
var ay = 0.0
for (o in sense.neighbours(range)) {
val dx = -world.deltaX(self.sx, o.sx)
val dy = -world.deltaY(self.sy, o.sy)
val d = hypot(dx, dy)
if (d < 1e-9) {
// Exactly coincident: separate along a direction fixed by the ids, not by chance.
ax += if (self.id < o.id) -1.0 else 1.0
} else {
val f = 1.0 - d / range
ax += dx / d * f
ay += dy / d * f
}
}
val m = hypot(ax, ay)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(ax / m * sense.speed, ay / m * sense.speed)
}
}
fun align(range: Double = 15.0, weight: Double = 1.0)
Match the mean heading of live agents within range — Reynolds' alignment term.
fun align(range: Double = 15.0, weight: Double = 1.0) {
require(range.isFinite() && range > 0.0) { "align range must be > 0, got $range" }
drive(weight) { _, sense ->
var ax = 0.0
var ay = 0.0
var n = 0
for (o in sense.neighbours(range)) { ax += o.svx; ay += o.svy; n++ }
if (n == 0) return@drive Vector2d(0.0, 0.0)
val m = hypot(ax, ay)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(ax / m * sense.speed, ay / m * sense.speed)
}
}
fun cohere(range: Double = 15.0, weight: Double = 0.7)
Steer toward the centroid of live agents within range — Reynolds' cohesion term.
fun cohere(range: Double = 15.0, weight: Double = 0.7) {
require(range.isFinite() && range > 0.0) { "cohere range must be > 0, got $range" }
drive(weight) { self, sense ->
var ax = 0.0
var ay = 0.0
var n = 0
for (o in sense.neighbours(range)) {
ax += world.deltaX(self.sx, o.sx)
ay += world.deltaY(self.sy, o.sy)
n++
}
if (n == 0) return@drive Vector2d(0.0, 0.0)
val m = hypot(ax / n, ay / n)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(ax / n / m * sense.speed, ay / n / m * sense.speed)
}
}
fun avoidEdges(margin: Double = 15.0, weight: Double = 1.0)
Turn away from the arena edges within margin — for a world with wrap = false, where
ScriptWorld.confine otherwise pins an agent against a wall with its velocity zeroed.
Does nothing on a wrapping world, which has no edges; adding it there is harmless rather than a mistake, so a script can be written once for both.
fun avoidEdges(margin: Double = 15.0, weight: Double = 1.0) {
require(margin.isFinite() && margin > 0.0) { "avoidEdges margin must be > 0, got $margin" }
drive(weight) { self, sense ->
if (world.wrap) Vector2d(0.0, 0.0) else {
var ax = 0.0
var ay = 0.0
if (self.sx < margin) ax += (margin - self.sx) / margin
if (self.sy < margin) ay += (margin - self.sy) / margin
if (self.sx > world.width - margin) ax -= (self.sx - (world.width - margin)) / margin
if (self.sy > world.height - margin) ay -= (self.sy - (world.height - margin)) / margin
Vector2d(ax * sense.speed, ay * sense.speed)
}
}
}
Walls — see alife_walls.kt
fun avoidWalls(clearance: Double = 6.0, weight: Double = 1.2)
Push away from any wall within clearance of the agent's surface, weighted by how close it
is — the wall counterpart of separate, and written in the same shape for the same reason.
Local steering, not navigation. This term knows about the walls near the agent and nothing about where the agent is trying to get to, so it will happily hold an agent against the near side of a barrier for as long as something else is pulling it that way. The verb for "reach that place, going round whatever is in the way" is route. Both at once is the usual arrangement: the route says which way, this keeps the corners comfortable.
Does nothing in a world with no walls, so a script can be written once for both.
@param clearance how far out from the agent's own radius the repulsion reaches
fun avoidWalls(clearance: Double = 6.0, weight: Double = 1.2) {
require(clearance.isFinite() && clearance > 0.0) { "avoidWalls clearance must be > 0, got $clearance" }
val cp = DoubleArray(2)
drive(weight) { self, sense ->
val field = world.wallField
if (field.isEmpty) Vector2d(0.0, 0.0) else {
val range = self.radius + clearance
val segs = field.segments()
var ax = 0.0
var ay = 0.0
var i = 0
while (i < segs.size) {
closestPointOnSegment(self.sx, self.sy, segs[i], segs[i + 1], segs[i + 2], segs[i + 3], cp)
val dx = self.sx - cp[0]
val dy = self.sy - cp[1]
val d = hypot(dx, dy)
if (d < range && d > 1e-12) {
val f = 1.0 - d / range
ax += dx / d * f
ay += dy / d * f
}
i += 4
}
val m = hypot(ax, ay)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(ax / m * sense.speed, ay / m * sense.speed)
}
}
}
fun cling
Follow the nearest wall, holding distance between the agent's surface and it — wall
following, the same blend of a tangential term and a radial correction that orbit is.
clockwise is which way along the wall, and a population that all clings the same way never
meets itself coming back; pass self.id % 2 == 0 for a script that wants both. Nothing
outside range pulls at all, so an agent in the middle of an open arena using only this term
coasts, which is the honest answer — there is no wall to follow.
@param distance the gap to hold between the agent's surface and the wall @param range how far away a wall can be and still be the one that is followed
fun cling(
distance: Double = 1.0,
clockwise: Boolean = true,
range: Double = 25.0,
weight: Double = 1.0
) {
require(distance.isFinite() && distance > 0.0) { "cling distance must be > 0, got $distance" }
require(range.isFinite() && range > 0.0) { "cling range must be > 0, got $range" }
val sign = if (clockwise) 1.0 else -1.0
val near = DoubleArray(5)
drive(weight) { self, sense ->
val field = world.wallField
if (field.isEmpty || !field.nearestInto(self.sx, self.sy, range, near)) Vector2d(0.0, 0.0) else {
val d = near[0]
val nx = near[3]
val ny = near[4]
if (hypot(nx, ny) < 1e-9) Vector2d(0.0, 0.0) else {
val standoff = self.radius + distance
val tx = ny * sign
val ty = -nx * sign
val pull = ((standoff - d) / standoff * 2.0).coerceIn(-1.5, 1.5)
val cx = tx + nx * pull
val cy = ty + ny * pull
val m = hypot(cx, cy)
if (m < 1e-9) Vector2d(0.0, 0.0) else Vector2d(cx / m * sense.speed, cy / m * sense.speed)
}
}
}
}
fun route
Head for t along a route: a radius-aware A* through the world's walls, smoothed, and
followed waypoint by waypoint.
This is the verb seek is not. seek points at the target and pushes, which is correct and
cheap when there is a straight line, and when there is a barrier in the way it presses into
the barrier for as long as the behaviour lasts — no amount of avoidWalls fixes that, because
avoidance is local and the decision to go the other way round a wall is not. The margin
between the two on a maze is AlifeWallsGallery's counterfactual.
The route is kept between steps, not recomputed each one. A new search happens when there is
no route, when the target has moved more than repathDistance from where the current route
was planned to, and — the one that matters most in practice — when the agent stops making
progress toward the waypoint it is aiming at for stallSeconds. Searches are rationed by
ScriptWorld.maxRouteSearchesPerStep; an agent refused a permit keeps following the route it
has and asks again next step.
The stall clause is not belt and braces, it is the difference between this working and not.
A plan is a straight line between waypoints and an agent is not: it has inertia, it has other
drives blended into it, and it is a disc rather than a point. So it clips corners. Clipping a
corner puts it in contact with a wall, WallResponse.SLIDE carries it along the face, and if
the waypoint it is aiming at is now straight through that wall, every subsequent step steers
it into the wall and the tangential component is zero. Without a stall clause there is
nothing that can ever change its mind. Measured, on the maze in AlifeWallsGallery: one
agent alone in the arena, no crowding, travelled 100 units and then pressed on the same
corner for 1 566 consecutive steps.
When there is no route at all — a target sealed behind a closed wall, or an arena with no walls in it — this falls back to exactly seek. That is deliberate: a drive that returned zero would leave the agent standing still with no indication of why, and "press toward it" remains the best available answer when there is no way round.
@param clearance extra room beyond the agent's radius the route must leave. 0 plans the tightest legal path; a little more keeps an agent off the corners it is technically allowed to graze, and it is worth at least matching whatever avoidWalls the same agent is running, or the two terms disagree round every corner. @param repathDistance how far a moving target may drift before the route is replanned @param stallSeconds how long the agent may fail to close on its current waypoint before the route is thrown away and replanned from where it actually is @param waypointRadius how close counts as having reached a waypoint; the default is derived from the agent's radius and clearance
body — 91 lines, from line 1978
fun route(
t: ScriptTarget,
clearance: Double = 0.5,
repathDistance: Double = 8.0,
stallSeconds: Double = 0.75,
waypointRadius: Double = -1.0,
weight: Double = 1.0
) {
require(clearance >= 0.0 && clearance.isFinite()) { "route clearance must be >= 0, got $clearance" }
require(repathDistance > 0.0 && repathDistance.isFinite()) {
"route repathDistance must be > 0, got $repathDistance"
}
require(stallSeconds > 0.0 && stallSeconds.isFinite()) {
"route stallSeconds must be > 0, got $stallSeconds"
}
val stallLimit = max(2L, world.ticksFor(stallSeconds))
var path: DoubleArray? = null
var cursor = 0
var plannedX = Double.NaN
var plannedY = Double.NaN
var closest = Double.MAX_VALUE
var stalled = 0L
drive(weight) { self, sense ->
val goal = t.resolve(world, self)
if (goal == null) Vector2d(0.0, 0.0) else {
val inflate = self.radius + clearance
val wpR = if (waypointRadius > 0.0) waypointRadius else max(inflate * 1.5, 1.5)
if (!world.wallField.isEmpty) {
val stale = path == null ||
stalled >= stallLimit ||
hypot(goal.x - plannedX, goal.y - plannedY) > repathDistance
if (stale && world.takeRoutePermit()) {
// Replanned from where the agent IS, not from where it set out. That is the
// whole recovery: a fresh search from against the wall goes back round the
// corner it clipped, whereas re-issuing the old plan would aim it at the
// same wall again.
path = world.findRoute(self.sx, self.sy, goal.x, goal.y, inflate)
cursor = 1
plannedX = goal.x
plannedY = goal.y
closest = Double.MAX_VALUE
stalled = 0L
}
}
val p = path
var aimX = goal.x
var aimY = goal.y
if (p != null && p.size >= 4) {
val n = p.size / 2
if (cursor < 1) cursor = 1
val was = cursor
// Two ways to be done with a waypoint. Reaching it is the obvious one; the
// other is that the NEXT one is already reachable in a straight line, which
// means the corner this waypoint was placed to get round is behind us.
//
// `clearPath` at the full inflated radius, and not a plain sight line: a
// zero-radius sight line lets an agent that can *see* past a corner skip to a
// waypoint it cannot *fit* past, after which it presses on the corner. An agent
// hugging a wall is inside its own inflation radius, so `clearPath` refuses
// every skip until it has room again, which is the conservative answer and the
// one that lets the stall clause above do the recovering.
while (cursor < n - 1 &&
world.wallField.clearPath(
self.sx, self.sy,
p[(cursor + 1) * 2], p[(cursor + 1) * 2 + 1],
inflate
)
) cursor++
while (cursor < n - 1 &&
hypot(self.sx - p[cursor * 2], self.sy - p[cursor * 2 + 1]) <= wpR
) cursor++
if (cursor != was) { closest = Double.MAX_VALUE; stalled = 0L }
aimX = p[cursor * 2]
aimY = p[cursor * 2 + 1]
}
val dx = world.deltaX(self.sx, aimX)
val dy = world.deltaY(self.sy, aimY)
val d = hypot(dx, dy)
// Progress is measured against the CLOSEST the agent has ever been to this
// waypoint, not against last step's distance. Distance-to-last-step calls a single
// slow step a stall and an agent oscillating about a fixed point "progressing" on
// every other step; a running minimum does neither.
if (d < closest - PROGRESS_EPSILON) { closest = d; stalled = 0L } else stalled++
if (d < 1e-9) Vector2d(0.0, 0.0) else Vector2d(dx / d * sense.speed, dy / d * sense.speed)
}
}
}
fun gradient
Climb (or descend) a ScalarField — chemotaxis, phototaxis, thermotaxis, whichever the field means.
A central difference at ±probe in each axis, normalised, so the steepness of the field
does not become a second speed control and a field in arbitrary units works without being
scaled first. probe is the agent's sensing baseline and it matters: too small and the
difference is lost in the field's own rounding, too large and the agent reads the average
slope of a region rather than the slope where it is.
This is a gradient sensor, not a search: it finds a local maximum and has nothing to say about the global one. A field with two lamps in it will split a population between them, which is the correct and interesting behaviour, not a bug to weight away.
@param towards true to climb, false to descend (seek the dark)
fun gradient(
field: ScalarField,
towards: Boolean = true,
probe: Double = 2.0,
weight: Double = 1.0
) {
require(probe.isFinite() && probe > 0.0) { "gradient probe must be > 0, got $probe" }
val sign = if (towards) 1.0 else -1.0
drive(weight) { self, sense ->
val gx = (field.at(self.sx + probe, self.sy) - field.at(self.sx - probe, self.sy)) / (2.0 * probe)
val gy = (field.at(self.sx, self.sy + probe) - field.at(self.sx, self.sy - probe)) / (2.0 * probe)
val m = hypot(gx, gy)
if (m < 1e-12) Vector2d(0.0, 0.0)
else Vector2d(gx / m * sign * sense.speed, gy / m * sign * sense.speed)
}
}
Targets, delegated from the enclosing scope
R3c. Targets are constructed by BehaviourScope, and moveFor { }'s receiver is this class, so
moveFor(4.0) { orbit(point(x, y)) } — the obvious line, and the first line most scripts try
— failed to compile with "cannot be called in this context with an implicit receiver". The
@ALifeBehaviour DslMarker is doing its job there and should not be relaxed: it is what stops a
wait appearing inside a steering block. So the constructors are re-exported here instead,
which is a handful of one-line delegates and makes the obvious line the working line.
fun point(p: Vector2d): ScriptTarget = scope.point(p)
A fixed point. See BehaviourScope.point.
fun point(x: Double, y: Double): ScriptTarget = scope.point(x, y)
A fixed point. See BehaviourScope.point.
fun actor(other: ScriptActor): ScriptTarget = scope.actor(other)
Another agent, resolving to nothing once it is dead. See BehaviourScope.actor.
fun nearest
The nearest live agent within range satisfying accept. See BehaviourScope.nearest.
fun nearest(
range: Double = max(world.width, world.height),
accept: (ScriptActor) -> Boolean = { true }
): ScriptTarget = scope.nearest(range, accept)
fun centroid(accept: (ScriptActor) -> Boolean = { true }): ScriptTarget = scope.centroid(accept)
The centroid of the live agents satisfying accept. See BehaviourScope.centroid.
fun ScriptWorld.toImg
The world as an RGB raster: walls, every agent's trail so far, the agents themselves, and any
marks (goals, waypoints) as rings — the frame a Box.animateEvolution clip of a scripted
population draws, in the way BoidFlock.toImg is the frame of a flock.
Drawn at scale pixels per world unit with the world's own origin, so x and y read the same as
in the script. Walls are grey, trails a dim blue, agents a warm white disc no smaller than
minDot pixels (an agent of radius 1 at scale 2 would otherwise be invisible), and marks green.
A trail that wraps across a torus seam is broken there rather than drawn across the frame.
@param trail brightness of the trails, 0 to leave them out
@param marks ring centres as x, y pairs in world units
@param markRadius ring radius in world units
body — 53 lines, from line 2147
fun ScriptWorld.toImg(
scale: Double = 2.0,
trail: Float = 0.45f,
minDot: Double = 2.5,
marks: List<Pair<Double, Double>> = emptyList(),
markRadius: Double = 6.0
): Img {
require(scale > 0.0) { "scale must be > 0, got $scale" }
val w = max(8, ceil(width * scale).toInt())
val h = max(8, ceil(height * scale).toInt())
val out = Img.zeros(w, h, 3)
fun put(px: Double, py: Double, r: Float, g: Float, b: Float, rad: Double) {
val ri = ceil(rad).toInt()
val cx = kotlin.math.floor(px).toInt()
val cy = kotlin.math.floor(py).toInt()
for (dy in -ri..ri) for (dx in -ri..ri) {
val xx = cx + dx
val yy = cy + dy
if (xx !in 0 until w || yy !in 0 until h) continue
if (hypot(xx + 0.5 - px, yy + 0.5 - py) > rad + 0.5) continue
val i = (yy * w + xx) * 3
out.data[i] = max(out.data[i], r)
out.data[i + 1] = max(out.data[i + 1], g)
out.data[i + 2] = max(out.data[i + 2], b)
}
}
fun seg(x0: Double, y0: Double, x1: Double, y1: Double, r: Float, g: Float, b: Float, rad: Double) {
val n = max(1, ceil(2.0 * hypot(x1 - x0, y1 - y0) * scale).toInt())
for (k in 0..n) {
val t = k.toDouble() / n
put((x0 + (x1 - x0) * t) * scale, (y0 + (y1 - y0) * t) * scale, r, g, b, rad)
}
}
if (trail > 0f) for (a in actors) {
for (i in 1 until a.trailX.size) {
val x0 = a.trailX[i - 1]; val y0 = a.trailY[i - 1]
val x1 = a.trailX[i]; val y1 = a.trailY[i]
if (kotlin.math.abs(x1 - x0) > width * 0.5 || kotlin.math.abs(y1 - y0) > height * 0.5) continue
seg(x0, y0, x1, y1, 0.35f * trail, 0.6f * trail, trail, 0.5)
}
}
val s = wallField.segments()
for (i in 0 until s.size / 4) seg(s[4 * i], s[4 * i + 1], s[4 * i + 2], s[4 * i + 3], 0.62f, 0.62f, 0.62f, 1.0)
for ((mx, my) in marks) {
val n = max(16, ceil(2.0 * Math.PI * markRadius * scale).toInt())
for (k in 0 until n) {
val th = 2.0 * Math.PI * k / n
put((mx + markRadius * cos(th)) * scale, (my + markRadius * sin(th)) * scale, 0.35f, 0.9f, 0.45f, 0.9)
}
}
for (a in actors) if (a.alive) put(a.x * scale, a.y * scale, 1f, 0.82f, 0.55f, max(minDot, a.radius * scale))
return out
}