Mannerisms Before Minds
Motion aimed as force
Everything that has moved in The Hollow so far was moved from outside it. Weight pulls down, wind pushes east, a point of attraction hauls things toward itself, and drag leans back against whatever is travelling. Release a leaf and it goes wherever that arithmetic sends it. Watch for a thousand ticks and you will never catch it choosing, because there is nothing in a leaf to choose with.
A behavior is a force, exactly like weight and wind and drag. Work out
the velocity the body would have if it were already doing what it wants, subtract the
velocity it actually has, and hand the difference to ApplyForce. An animal
reads differently on screen, and the difference arrives long before you know
anything about the animal. It heads somewhere. It slows down as it gets there. It turns
away from things it does not care for, and when nothing in particular is happening it
still moves, drifting about instead of standing perfectly still. A beetle manages all
four. None of it needs a brain. All of it needs the motion to be aimed.
The obvious way to aim motion is to write the velocity down. Work out the direction from
the creature to whatever it is after, multiply by how fast that creature goes, assign the
result to Vel. Two lines, and on screen the creature does indeed set off
toward the target.
It has also just left the world. An assignment does not add to the velocity, it replaces it, so every other force acting on that body during the tick is thrown away unread. Wind cannot blow the creature. Water cannot slow it. A slope cannot make it stumble, and nothing can shove it, because a body whose velocity is dictated once a tick has no memory of a push. Worse, every system that touches bodies now needs a list of which bodies are steered so it can skip them, and any system that forgets produces motion that flickers between two answers.
This is the payoff the accumulator was built for. It was written so that any system could add a force knowing nothing about the body and nothing about the other systems, and a behavior is precisely such a system: it works out one vector, calls one method, and goes away. Composition comes free. A walker being pushed by a gust while heading for a tuft of grass is one sum of two vectors, and neither the gust nor the grass has to be told the other exists.
Four things follow from that. The steering force itself, plus the two limits that make it an animal instead of an arrow. Four behaviors built out of it, each with a flaw the numbers show plainly. The arithmetic of combining several when they disagree. And a walker that grazes, bolts when something comes too close, settles, and goes back to grazing, with nothing anywhere in it that could be called a mind.
One seek by hand
A body wants to reach a point. Two numbers describe what the body is capable of: the fastest it can travel, and the hardest it can push itself around. Both are properties of the creature, in the same way mass is, and everything in this chapter comes out of them. Work one tick of it by hand before writing a line.
A walker stands at (40, 100). The grass is at (136, 28). The walker's top speed is 2.4 world pixels a tick and the hardest it can steer is 0.3. Right now it is drifting south-west at (−0.480, 1.760), which is the wrong way entirely.
Step one: where is the target from here? Subtract the positions, (136 − 40, 28 − 100), giving (96, −72). Its length is 120, since 96 by 72 is a three-four-five triangle scaled by 24. Throw the length away and keep the direction: (0.8, −0.6).
Step two: the desired velocity. That direction at full speed is 2.4 × (0.8, −0.6), which is (1.920, −1.440). This is the velocity the walker would have if it were already doing what it wants.
Step three: the steering force. Subtract what the walker has from what it wants. (1.920 − (−0.480), −1.440 − 1.760) is (2.400, −3.200), and that vector has length 4.000. Notice what it contains. It points north-east, partly because the grass is north-east and partly because the walker was going south and that has to be undone first. The difference cancels the wrong motion and supplies the right motion in a single vector.
Step four: the cut. Four is far more than 0.3, so the walker cannot push that hard. Keep the direction, set the length to 0.3: multiply by 0.3 ÷ 4.0, giving (0.180, −0.240).
desired = unit(target − position) × maxSpeed
steering = desired − velocity, cut to maxForce
That force goes to the accumulator, the tick divides by a mass of 1, and the velocity becomes (−0.300, 1.520). The walker is still going south. It has turned by about four degrees. That is what a limited steering force buys: turning takes time, and time spent turning is most of what makes a moving thing look like it has a body.
// internal/field/steer.go
// Gait is what a body is physically able to do: the fastest it can
// travel and the hardest it can push itself around. Every steering
// behavior in this world answers with a force no larger than MaxForce,
// aiming at a velocity no faster than MaxSpeed, and those two numbers
// are the whole difference between a creature and a thrown stone.
type Gait struct {
MaxSpeed float64 // world pixels per tick
MaxForce float64 // the largest steering force this body can produce
}
// Steer turns a desired velocity into a force for the accumulator: the
// velocity the body wants, minus the velocity it actually has, cut down
// to what the body can push with. It is the only place in this file
// where a behavior becomes a force.
func (g Gait) Steer(b Body, desired Vec2) Vec2 {
f := desired.Sub(b.Vel)
if f.Len() > g.MaxForce {
f = f.Unit().Scale(g.MaxForce)
}
return f
}
// Seek is the force that carries a body toward a target at full speed:
// the desired velocity points at the target and is as long as the body
// can go.
func (g Gait) Seek(b Body, target Vec2) Vec2 {
return g.Steer(b, target.Sub(b.Pos).Unit().Scale(g.MaxSpeed))
}
$ go run ./cmd/steer -mode hand
steer: one seek, mass 1.0, max speed 2.4, max force 0.3
target 136.000,28.000
tick position current desired steering |steer| force
1 40.000,100.000 -0.480,1.760 1.920,-1.440 2.400,-3.200 4.000 0.180,-0.240
2 39.700,101.520 -0.300,1.520 1.908,-1.456 2.208,-2.976 3.706 0.179,-0.241
3 39.579,102.799 -0.121,1.279 1.896,-1.471 2.018,-2.750 3.411 0.177,-0.242
4 39.635,103.836 0.056,1.037 1.886,-1.484 1.830,-2.521 3.115 0.176,-0.243
5 39.867,104.631 0.232,0.794 1.877,-1.496 1.644,-2.290 2.819 0.175,-0.244
6 40.275,105.181 0.407,0.551 1.868,-1.506 1.461,-2.057 2.523 0.174,-0.245
7 40.856,105.487 0.581,0.306 1.861,-1.516 1.280,-1.822 2.226 0.172,-0.245
8 41.609,105.548 0.754,0.061 1.854,-1.524 1.101,-1.584 1.929 0.171,-0.246
after 8 ticks: at 42.534,105.362 doing 0.925,-0.186, 121.329 from the target
Row 1 is the hand calculation, column for column. The interesting column is
|steer|: it starts at 4.000 and comes down every tick, because each tick
the walker's velocity gets a little closer to the velocity it wanted, so there is less
difference left to fix. The cut is doing all the work in the meantime. Every entry in
the force column has length 0.3 exactly, and the direction of that 0.3 turns slowly as
the walker comes round.
Look at the position column and nothing much happens: eight ticks in, the walker has crept from y 100 to y 105, the wrong way, still shedding the southward motion it started with. Then read the current-velocity column beside it, where the southward 1.760 has fallen to 0.061 and the eastward number has climbed from −0.480 to 0.754. The turn was happening the whole time. A creature that could reverse instantly would have skipped all of that, and skipping it is what makes a thing look like a cursor instead of an animal.
Figure 31.1 — the whole of steering: a velocity the body wants, minus the one it has, cut to what it can push with, handed to the accumulator like any other force.
Arrival radius 9.600
Seek has one behavior and it never stops having it. The desired velocity is full speed at the target from any distance, including from half a pixel away, so a body that reaches its target is still being told to travel at 2.4 pixels a tick. It sails past, turns round over the next several ticks, comes back, and sails past the other way.
$ go run ./cmd/steer -mode seek -ticks 240
steer: seek only, target 96.000,64.000, max speed 2.4, max force 0.3
tick distance speed
20 44.780 2.400
40 2.920 2.100
60 9.080 0.900
80 8.920 0.300
100 5.480 1.500
120 3.680 2.100
140 8.320 0.900
160 9.680 0.300
180 4.720 1.500
200 2.920 2.100
220 9.080 0.900
240 8.920 0.300
nearest approach 0.820 at tick 39, and it turned round to come back 13 times
at tick 240 it is 8.920 away doing 0.300
Thirteen turns in 240 ticks and the pattern repeats exactly every 40, because after the first pass the whole thing is one motion along one line: overshoot by about nine pixels, decelerate at 0.3 a tick, come back, overshoot again. This is not a bug and it is not useless. A moth round a lamp does this. A creature circling something it has not decided about does this. It is never an arrival.
Arrival needs the desired velocity to know how close the body is. Outside some radius, full speed as before. Inside it, ask for a speed proportional to the distance remaining, so that the request reaches zero exactly where the target is. The braking radius is the only new number, and it cannot be chosen freely.
The walker is doing 2.4 and has to reach 0. The strongest force it can produce is 0.3, and dividing by its mass of 1 gives a deceleration of 0.3 pixels per tick per tick, so shedding 2.4 takes eight ticks. During those eight ticks it is still moving, at speeds running down from 2.4 to 0, averaging 1.2, so it covers about 8 × 1.2 = 9.6 pixels while stopping.
stopping distance = maxSpeed × maxSpeed × mass ÷ (2 × maxForce)
2.4 × 2.4 × 1 ÷ 0.6 is 9.6, the same answer written once instead of in two steps. A braking radius under 9.6 asks this walker for a stop it has no way to perform: it will still be near full speed when it runs out of room, and it will go past. A radius of 24 leaves plenty of margin, and inside it the desired speed is 0.1 of whatever the distance is, so the walker settles by taking a tenth off the gap every tick.
// internal/field/steer.go
// Flee is Seek with the desired velocity turned around: away from the
// thing rather than toward it, and otherwise the same arithmetic.
func (g Gait) Flee(b Body, from Vec2) Vec2 {
return g.Steer(b, b.Pos.Sub(from).Unit().Scale(g.MaxSpeed))
}
// Arrive is Seek with a braking radius. Outside the radius the body
// wants full speed. Inside it, the speed it wants falls off in
// proportion to how far it still has to go, reaching zero at the target
// itself.
func (g Gait) Arrive(b Body, target Vec2, brake float64) Vec2 {
d := target.Sub(b.Pos)
r := d.Len()
speed := g.MaxSpeed
if r < brake {
speed = g.MaxSpeed * r / brake
}
return g.Steer(b, d.Unit().Scale(speed))
}
// StopRadius is the shortest braking radius this gait can honour on a
// body of mass m: the distance the body covers while shedding its top
// speed at the hardest it can push. A braking radius under this number
// asks for a stop the body cannot perform.
func (g Gait) StopRadius(m float64) float64 {
return g.MaxSpeed * g.MaxSpeed * m / (2 * g.MaxForce)
}
$ go run ./cmd/steer -mode arrive -ticks 240
steer: arrive, target 96.000,64.000, braking radius 24.0
shortest radius this gait can stop inside: 9.600
tick distance speed wanted
20 44.780 2.400 2.400
40 7.274 0.808 0.808
60 0.884 0.098 0.098
80 0.108 0.012 0.012
100 0.013 0.001 0.001
120 0.002 0.000 0.000
140 0.000 0.000 0.000
160 0.000 0.000 0.000
180 0.000 0.000 0.000
200 0.000 0.000 0.000
220 0.000 0.000 0.000
240 0.000 0.000 0.000
ends 0.0000 from the target doing 0.0000
The speed and wanted columns are equal from tick 40 onward,
which says the walker is getting exactly what it asks for: at 24 pixels out the request
is small enough that a 0.3 force can keep up with it, so the cut stops binding and the
body tracks its desire precisely. The distance then falls by a tenth every tick, which
is a curve that halves roughly every seven ticks and never quite reaches zero. By tick
140 the difference is smaller than a printed thousandth, and on a screen of whole pixels
it stopped mattering around tick 80.
Flee is one subtraction reversed, and the smallness of that change is the point. The
direction to run is the direction from the frightening thing to the body, which is the
same vector as before with its ends swapped. Everything else, the top speed, the cut, the
accumulator, is untouched. Behaviors written this way tend to be one line, because all
the machinery lives in Steer and each behavior only has to answer the
question of what velocity it would like.
Wander with memory
A creature that only ever seeks, arrives and flees stands perfectly still whenever it has nothing to do, and stillness is the single most artificial thing a living creature can do on screen. It needs an idle motion: a drift that never repeats and never settles.
The tempting version is a random direction every tick, drawn from the world's seeded stream so it still replays. It does not work, and the reason it does not work is not the one people expect. Successive random directions have nothing to do with each other, so over any handful of ticks they cancel, and what the accumulator receives averages out to almost nothing. The body dithers on the spot. What is needed is randomness that remembers: an angle that gets nudged a little each tick instead of being redrawn. Keep that angle on a small circle projected in front of the body, seek the point it names, and the body commits to a direction for a while before drifting off it.
// internal/field/steer.go
// Wander is a heading that drifts. The body projects a circle a little
// in front of itself, keeps one angle on that circle from tick to tick,
// nudges the angle by a small seeded amount, and seeks whatever point
// the angle names. The angle is what carries across ticks, and carrying
// across ticks is the entire trick.
type Wander struct {
Ahead float64 // how far in front of the body the circle sits
Radius float64 // how far off the heading its target may sit
Jitter float64 // the most the angle may change in one tick, in radians
angle float64 // where on the circle the target is, right now
}
// Target is the point the wander is steering for this tick: the circle's
// centre plus one radius in the direction the drifting angle names. The
// angle is measured from the body's own heading, so the circle turns
// with the body instead of standing still in the world.
func (w *Wander) Target(b Body, rng *rand.Rand) Vec2 {
w.angle += (rng.Float64()*2 - 1) * w.Jitter
head := b.Vel.Unit()
if head == (Vec2{}) {
head = Vec2{X: 1}
}
centre := b.Pos.Add(head.Scale(w.Ahead))
a := math.Atan2(head.Y, head.X) + w.angle
return centre.Add(Vec2{X: math.Cos(a), Y: math.Sin(a)}.Scale(w.Radius))
}
// Force is the wander as a force: seek the point on the circle.
func (w *Wander) Force(g Gait, b Body, rng *rand.Rand) Vec2 {
return g.Seek(b, w.Target(b, rng))
}
// Jump is the wander that does not work, kept so the two can be run
// against each other: a direction drawn fresh every tick with nothing
// whatever carried over from the last one.
func Jump(g Gait, b Body, rng *rand.Rand) Vec2 {
a := rng.Float64() * 2 * math.Pi
return g.Steer(b, Vec2{X: math.Cos(a), Y: math.Sin(a)}.Scale(g.MaxSpeed))
}
$ go run ./cmd/steer -mode wander -ticks 240 -shot wander.png
steer: two wanders from 96.000,64.000, seed 5, 240 ticks
circle: 12 ahead, radius 8, jitter 0.50 radians a tick
tick circle: speed travelled turn jump: speed travelled turn
40 2.391 92.2 6.1 1.138 37.1 14.8
80 2.000 180.9 6.9 0.766 62.4 20.6
120 2.388 273.3 6.6 1.075 85.0 23.3
160 2.251 365.5 6.7 0.962 119.0 21.1
200 2.353 452.9 6.9 0.301 136.4 26.7
240 2.400 541.6 7.0 0.833 168.4 25.0
circle: mean speed 2.257, path 541.6, mean turn 7.0 degrees a tick frame 3b1fe4642101
jump: mean speed 0.702, path 168.4, mean turn 25.0 degrees a tick frame 4f500817bec9
wrote circle-wander.png
wrote jump-wander.png
Same seed, same stream, same starting velocity, and the two walkers behave nothing alike. The circle wanderer holds 2.257 of a possible 2.4 and covers 541 pixels, turning about seven degrees a tick, which is a long unhurried curve. The jump wanderer averages 0.702, covers 168, and turns 25 degrees a tick: it swings three and a half times as hard while getting a third as far. A creature that turns constantly and goes nowhere is what a viewer reads as broken.
The two PNGs put it beyond argument. circle-wander.png is a long loose
trail that crosses the valley, wraps at the edge and comes back through. In
jump-wander.png the trail is a short scribble knotted around a single patch
of ground the size of two cells. Both trails were drawn from the same numbers, one
stream, one seed, and both frames hash the same on every run.
The 52-tick alarm
Now put it together. A walker feeding at a tuft of grass wants two things at once: to be
at the tuft, and to keep shifting about while it is there. That is Arrive at
the tuft plus a smaller helping of Wander, added, since adding forces is what
the accumulator has always done. Then a predator comes over the rim, and the walker wants
a third thing that flatly contradicts the first two.
// cmd/steer/main.go — the tick
// Stream 3 of this seed: the terrain draws from stream 0, the world's
// laws from stream 1, released bodies from stream 2 and chapter 30's
// weathers from streams 4 to 7, so a walker's wandering disturbs none
// of them.
rng := rand.New(rand.NewPCG(seed, 3))
...
pred = predAt(t, park)
r := pred.Sub(b.Pos).Len()
if r < startle {
fear = 1
} else {
fear *= 0.94
}
feed := walk.Arrive(b, tuft, 24).Add(w.Force(walk, b, rng).Scale(0.6))
flight := walk.Flee(b, pred)
b.ApplyForce(combine(blend, feed, flight, fear, r < startle))
b.Step()
// cmd/steer/main.go
// combine settles the disagreement when feeding and flight both want the
// tick. Three rules, and the run's -blend flag picks between them.
func combine(blend string, feed, flight field.Vec2, fear float64, near bool) field.Vec2 {
switch blend {
case "sum": // fixed weights: both behaviors are always heard
if near {
return feed.Add(flight.Scale(2))
}
return feed
case "switch": // flight wins outright, and stops the instant it can
if near {
return flight
}
return feed
}
// flight wins outright, and lets go slowly
return flight.Scale(fear).Add(feed.Scale(1 - fear))
}
Three ways to settle a disagreement, and they are easier to tell apart on paper than in a run. The weighted sum keeps both behaviors in the accumulator all the time and lets the weights decide who dominates. The override lets flight take the tick outright and hands it straight back the moment the predator is one pixel outside the radius. The third does both: while the predator is inside the radius the alarm is 1 and grazing has no vote at all, and afterwards the alarm falls by six percent a tick, so the walker spends the next fifty or so ticks somewhere between fleeing and feeding.
Park the predator where it will not go away, so the disagreement has to be resolved rather than outlived, and each rule shows its character in one number: how long it let something dangerous stay close.
$ go run ./cmd/steer -mode graze -ticks 240 -blend sum -park | tail -3
240 0.734 43.28 0.350 11.28 cacf47c06635 b76935018c65ca58
the predator got within 12.93, spent 79 ticks inside the radius,
and the fastest the walker ran while it was in there was 2.448
$ go run ./cmd/steer -mode graze -ticks 240 -blend switch -park | tail -3
240 0.690 44.02 0.326 12.07 dff6c9b6d87c a6cddf5964a9c8c8
the predator got within 13.99, spent 89 ticks inside the radius,
and the fastest the walker ran while it was in there was 2.400
$ go run ./cmd/steer -mode graze -ticks 240 -blend fear -park | tail -3
240 0.101 83.21 1.658 70.75 aabdb05d264d 976c3857a01fbb0c
the predator got within 13.99, spent 52 ticks inside the radius,
and the fastest the walker ran while it was in there was 2.400
The middle line of each summary carries the verdict. Under the weighted sum the walker spends 79 of 240 ticks with a predator inside its radius, and the reason is in the arithmetic: the grass is on the far side of the predator, so the grazing force points at the danger while the flight force points away, and their sum is a compromise neither behavior would have chosen. The walker hovers, half-feeding, eleven pixels from its tuft and forty-three from a predator. Any fixed weight buys the same problem at a different distance. Raise it and the walker can never feed within sight of anything; lower it and it feeds while being eaten.
The plain override does worse, at 89 ticks, and the fault is the opposite one. The moment the predator is a pixel outside the radius the walker forgets entirely and turns back toward the grass, which walks it into the radius again, which turns it round again. It orbits the boundary. The decaying alarm cuts that to 52 ticks by taking the two apart: the trigger is still a hard radius, but the letting-go is gradual, so the walker carries its fright out past the boundary and is well clear before grazing gets a say. Of the three, it is the one to keep, and the reason is that it stops treating a threshold and a state as the same thing.
While tuning the startle, the first version of it felt sluggish: the walker took most of a second to get up to speed, and a frightened animal ought to be quicker than that. The obvious lever is the steering limit, so it went up, and then up again, and then off altogether, since a cap that never binds might as well not exist. Watch the ticks around the moment the predator crosses the radius:
$ go run ./cmd/steer -mode graze -ticks 40 -blend fear -force 0 -from 33 -to 40 | head -14
steer: a walker grazing at 150.000,64.000, seed 5, flight overrides grazing, and the fear decays
startle radius 40, predator walks west at 1.600 and keeps going
tick fear predator speed from tuft frame numbers
0 0.000 178.18 0.400 120.15 054c46392206 75fb133e21c2537a
20 0.000 99.52 2.141 73.47 349270a205c9 000a10a1ca7b2393
t= 33 fear 0.000 pred 50.83 vel 2.351,0.262 speed 2.366 pos 106.776,64.568
t= 34 fear 0.000 pred 46.89 vel 2.393,0.129 speed 2.397 pos 109.169,64.697
t= 35 fear 0.000 pred 42.92 vel 2.403,-0.071 speed 2.404 pos 111.572,64.626
t= 36 fear 1.000 pred 38.92 vel -2.395,0.162 speed 2.400 pos 109.177,64.788
t= 37 fear 1.000 pred 39.72 vel -2.394,0.168 speed 2.400 pos 106.783,64.956
t= 38 fear 0.940 pred 40.52 vel -2.105,0.140 speed 2.109 pos 104.678,65.096
t= 39 fear 0.884 pred 41.04 vel -1.856,0.144 speed 1.862 pos 102.822,65.240
t= 40 fear 0.831 pred 41.30 vel -1.635,0.105 speed 1.638 pos 101.188,65.345
40 0.831 42.94 1.638 48.83 2027168a252a dbfbf71e0317bbb4
Read the velocity column across ticks 35 and 36. The walker is travelling east at 2.403 pixels a tick, and one tick later it is travelling west at 2.395, at full speed both ways, with nothing in between. No deceleration, no turn, no ticks spent changing its mind. On screen the sprite does not run away; it is somewhere else, facing the other way, on the next frame. The word for that is a teleport, and no amount of frightening it harder was going to fix it, because the problem was that the fright had become infinitely effective.
The proof is in the plainest run there is. Take the cap off the single seek from the first section:
$ go run ./cmd/steer -mode hand -force 0
steer: one seek, mass 1.0, max speed 2.4, max force +Inf
target 136.000,28.000
tick position current desired steering |steer| force
1 40.000,100.000 -0.480,1.760 1.920,-1.440 2.400,-3.200 4.000 2.400,-3.200
2 41.920,98.560 1.920,-1.440 1.920,-1.440 0.000,0.000 0.000 0.000,0.000
3 43.840,97.120 1.920,-1.440 1.920,-1.440 -0.000,0.000 0.000 -0.000,0.000
4 45.760,95.680 1.920,-1.440 1.920,-1.440 0.000,-0.000 0.000 0.000,-0.000
5 47.680,94.240 1.920,-1.440 1.920,-1.440 0.000,0.000 0.000 0.000,0.000
6 49.600,92.800 1.920,-1.440 1.920,-1.440 0.000,0.000 0.000 0.000,0.000
7 51.520,91.360 1.920,-1.440 1.920,-1.440 -0.000,0.000 0.000 -0.000,0.000
8 53.440,89.920 1.920,-1.440 1.920,-1.440 0.000,0.000 0.000 0.000,0.000
after 8 ticks: at 55.360,88.480 doing 1.920,-1.440, 100.800 from the target
On tick 1 the current velocity becomes the desired velocity, to the digit. From tick 2 onward the steering force reads 0.000 forever, because there is no difference left between what the walker wants and what it has. Follow the arithmetic and the reason is unavoidable: the force is desired minus current, the accumulator divides by a mass of 1, and the update adds the result to the velocity, so the new velocity is current plus desired minus current, which is desired. An uncapped steering force on a body of mass 1 is an assignment written the long way round, and everything the first section said about assignment applies to it. The cap is what keeps steering a force instead of an instruction.
One number decides where that boundary sits. The steering force can never be longer than twice the top speed, since it is the difference between two vectors of length at most 2.4. So any cap of 4.8 or more, on a body of mass 1, binds on no tick ever. At 0.3 it binds constantly, and a walker takes eight ticks to turn round. Between those two lies every animal in this world.
With the cap back at 0.3, let the predator walk across the valley instead of parking, and watch the whole encounter from grazing to grazing.
$ go run ./cmd/steer -mode graze -ticks 240 -blend fear -shot startle.png
steer: a walker grazing at 150.000,64.000, seed 5, flight overrides grazing, and the fear decays
startle radius 40, predator walks west at 1.600 and keeps going
tick fear predator speed from tuft frame numbers
0 0.000 178.18 0.400 120.15 054c46392206 75fb133e21c2537a
20 0.000 102.02 2.346 75.95 55c019b6268b 66961ee5d5669540
40 1.000 25.64 1.201 31.34 d97ffb418332 0fe50327d2d6d14f
60 1.000 30.25 2.400 55.62 cf0653926fc7 b5300eaf6074c36f
80 0.395 75.36 1.932 86.15 c66c5efa53b1 68a760b7fadf9114
100 0.115 116.04 1.546 99.40 75862d3f02ec f4d9484e1df7a8b2
120 0.033 133.45 2.375 58.78 8a5113209e7c 84e15e4377fc5059
140 0.010 164.69 1.598 13.78 9fd98fbbc369 1cb40e777723efd4
160 0.003 197.29 0.194 0.84 09d1731e946e 555ecba46888cd3c
180 0.001 230.00 0.191 0.09 d4044a8d87db d157efb8012a10ff
200 0.000 262.17 0.162 0.50 5c70c2cbef73 2c1f7c2d1566df8e
220 0.000 294.06 0.135 0.58 5c70c2cbef73 ddb17063e51e6d50
240 0.000 325.32 0.151 0.87 09d1731e946e 0f1cf32fa743996d
the predator got within 13.99, spent 29 ticks inside the radius,
and the fastest the walker ran while it was in there was 2.400
wrote startle.png
Read the alarm column against the distance to the grass. The walker crosses the valley
and gets within 31 pixels of the tuft. The predator arrives, the alarm goes to 1, and by tick 60
the walker is running flat out and 55 pixels from the tuft, going the wrong way. It peaks
at 99 pixels away around tick 100 with the alarm down to 0.115, turns, comes back through
58 and 13, and from tick 160 onward it is within a pixel of the grass, moving at a fifth
of a pixel a tick: feeding, shifting about, occasionally taking a step. At tick 240
startle.png is a walker standing on its tuft with the whole valley empty
behind it.
Nothing in that walker holds a plan. There is no state machine, no decision, no if-tree about what to do next. There is one number that says how alarmed it is, three behaviors added up with that number deciding the weights, and a mass. What a viewer sees is an animal that was eating, got a fright, ran, calmed down over several seconds and came back to its dinner, and every part of that reading was supplied by the viewer.
Behavior as caller
The accumulator asked every force to answer one question, what vector do you contribute this tick, and refused to care where the answer came from. Weight computes it from mass. Drag computes it from velocity. A field computes it from position. A steering behavior computes it from a desired velocity, and that is the only new idea in this chapter: an intention can be written as a velocity, and the gap between an intention and a fact is a force. Once that translation exists, wanting composes with physics without either side being modified, because both of them are just numbers being added.
The two limits do the rest. Top speed decides how long the desired vector is, and it is the difference between a mouse and a horse. The steering cap decides how much of the difference gets through in one tick, and it is the difference between a horse and a cursor. Every character trait these walkers have came out of those two numbers plus a braking radius: how fast, how sharply, how carefully. That is a small enough set of dials that a creature's whole manner of moving can be written down as three numbers and handed to something that generates creatures.
It also settles a question about minds before the question needs an answer. Deciding that the water matters more than the berries is one kind of work; getting to the water without walking into a rock is another, and this chapter did the second one. A chooser hands over a target, a thing to avoid, and how much each matters; the mannerisms turn that into forces and the accumulator turns the forces into motion.
The alarm earns its place as a number between 0 and 1 instead of a true-or-false for the same reason. A threshold answers a question about the world; a state answers a question about the animal, and animals stay frightened for a while. Any quantity that decays like that smooths a hard trigger into a behavior with duration: hunger after a meal, exhaustion after a sprint, wariness after being chased. One multiply a tick, and a switch becomes a mood.
Movement that reads as intent
- Given a position, a target, a current velocity and a gait's two numbers, I can
produce the desired velocity, the raw steering force, its length and the force after
the cut, and check all four against
-mode hand. - I can say what a steering force becomes when the cut never binds, and point at the column in the uncapped run that proves it.
- From a top speed, a mass and a steering limit I can compute the shortest braking radius that gait can honour, and say what a smaller one does when the body reaches the target.
- I can explain, from the path and turn columns rather than the picture, why a direction drawn afresh each tick leaves a body dithering while a drifting angle carries it across the valley.
- Given the three parked-predator summaries, I can say which combination rule left something dangerous close for longest, and name the arithmetic that did it.
- I can add a fifth behavior to this walker without touching the update, the accumulator, the gait, or any behavior already there.
Exercise 1 — ask for a stop the walker cannot make. The braking radius floor for this gait is 9.600. Run arrive with a radius of 6 and watch the ticks either side of the target. Predict how far past it goes before it turns.
At 6.380 out it is still doing 2.400, because the desired speed only starts falling inside 6 and the body needs 9.6 pixels to shed 2.4. It crosses the target at tick 39 with 1.800 still on the clock and keeps going.
$ go run ./cmd/steer -mode arrive -ticks 64 -brake 6 -from 36 -to 46
steer: arrive, target 96.000,64.000, braking radius 6.0
shortest radius this gait can stop inside: 9.600
tick distance speed wanted
20 44.780 2.400 2.400
t= 36 distance 6.380 wanted 2.400 speed 2.400 pos 90.556,67.327
t= 37 distance 3.980 wanted 2.400 speed 2.400 pos 92.604,66.075
t= 38 distance 1.880 wanted 1.592 speed 2.100 pos 94.396,64.980
t= 39 distance 0.080 wanted 0.752 speed 1.800 pos 95.932,64.042
t= 40 distance 1.420 wanted 0.032 speed 1.500 pos 97.212,63.260
40 1.420 1.500 0.032
t= 41 distance 2.620 wanted 0.568 speed 1.200 pos 98.236,62.634
t= 42 distance 3.520 wanted 1.048 speed 0.900 pos 99.003,62.165
t= 43 distance 4.120 wanted 1.408 speed 0.600 pos 99.515,61.852
t= 44 distance 4.420 wanted 1.648 speed 0.300 pos 99.771,61.695
t= 45 distance 4.420 wanted 1.768 speed 0.000 pos 99.771,61.695
t= 46 distance 4.120 wanted 1.768 speed 0.300 pos 99.515,61.852
60 0.004 0.003 0.003
64 0.000 0.000 0.000
ends 0.0005 from the target doing 0.0003
It ends up 4.420 past the target, stops dead on tick 45, and starts back. Every
tick after that happens inside the braking radius, where the speed it asks for
falls along with the distance, so it converges instead of orbiting: the run ends
half a thousandth of a pixel out. Compare the wanted and
speed columns on the rows before the overshoot. The gap between them
is the request the walker's legs are refusing.
Exercise 2 — take the cap off something lighter. The uncapped
seek on a mass of 1 turned into an assignment. Predict what it does on a mass of 0.5
before running it, then run -mode hand -force 0 -mass 0.5.
Dividing by half a mass doubles the acceleration, so the velocity gains twice the difference it needed and lands as far past the desired velocity as it started short of it. Next tick it does the same thing in reverse. The velocity reflects across the desired velocity every tick, forever.
$ go run ./cmd/steer -mode hand -force 0 -mass 0.5
steer: one seek, mass 0.5, max speed 2.4, max force +Inf
target 136.000,28.000
tick position current desired steering |steer| force
1 40.000,100.000 -0.480,1.760 1.920,-1.440 2.400,-3.200 4.000 2.400,-3.200
2 44.320,95.360 4.320,-4.640 1.934,-1.421 -2.386,3.219 4.007 -2.386,3.219
3 43.868,97.158 -0.452,1.798 1.919,-1.441 2.371,-3.239 4.014 2.371,-3.239
4 48.159,92.478 4.291,-4.680 1.935,-1.420 -2.356,3.259 4.022 -2.356,3.259
5 47.738,94.318 -0.421,1.839 1.919,-1.442 2.340,-3.281 4.030 2.340,-3.281
6 51.996,89.595 4.259,-4.723 1.935,-1.419 -2.323,3.303 4.039 -2.323,3.303
7 51.609,91.479 -0.388,1.884 1.918,-1.443 2.306,-3.327 4.048 2.306,-3.327
8 55.832,86.710 4.224,-4.770 1.936,-1.418 -2.287,3.352 4.058 -2.287,3.352
after 8 ticks: at 55.481,88.643 doing -0.351,1.934, 100.801 from the target
The odd ticks read −0.452, 1.798 and the even ticks 4.291, −4.680: a body doing six pixels one frame and under two the next, sign flipped, on a field 192 across. It still closes on the target, and at exactly the rate the mass-1 run managed: 100.801 away after eight ticks against that run's 100.800. The two alternating velocities average to the desired one, so the progress is right and the motion is a vibration.
Exercise 3 — feed in a crosswind. Give the arriving walker a steady wind of 0.200 east as well, and predict exactly where it comes to rest. It is not the target.
At rest the desired speed is the distance times 0.1, so arrive's force is 0.1 times the distance, toward the target. The wind is 0.200 the other way. They cancel where 0.1 × distance = 0.200, which is 2.000 pixels downwind.
// cmd/exp/main.go — the body of main, and nothing else
walk := field.Gait{MaxSpeed: 2.4, MaxForce: 0.3}
wind := field.Vec2{X: 0.2, Y: 0}
target := field.Vec2{X: 96, Y: 64}
b := field.NewBody(field.Vec2{X: 24, Y: 108}, 1)
fmt.Printf("arrive into a steady wind %.3f,%.3f, braking radius 24\n", wind.X, wind.Y)
fmt.Println(" tick distance speed downwind of the tuft")
for t := 1; t <= 200; t++ {
b.ApplyForce(walk.Arrive(b, target, 24))
b.ApplyForce(wind)
b.Step()
if t%25 == 0 {
fmt.Printf(" %4d %9.3f %7.3f %14.3f\n",
t, target.Sub(b.Pos).Len(), b.Vel.Len(), b.Pos.X-target.X)
}
}
$ go run ./cmd/exp
arrive into a steady wind 0.200,0.000, braking radius 24
tick distance speed downwind of the tuft
25 26.057 2.567 -21.349
50 6.482 0.060 6.479
75 2.372 0.041 2.372
100 2.027 0.003 2.027
125 2.002 0.000 2.002
150 2.000 0.000 2.000
175 2.000 0.000 2.000
200 2.000 0.000 2.000
2.000, to the last printed digit, and it holds there. Nobody wrote a rule about standing downwind. It fell out of two forces balancing, which is what putting a behavior in the accumulator buys you: the creature obeys the weather without knowing there is any.