Step 5 — Seeing
Deutsche Fassung · Translated from the German original.

As far as she sees, so far the choice reaches.
Goal: The ant sees what is going on around it, plus a threshold below which an answer is thrown away.

Twelve rounds, three fruits in the stores. The green is the trail — it is made further down in this chapter.
Up to here the decision model has more or less guessed, because there was nothing in the state from which anything could be concluded. That changes now — and the colony starts to work.
Distance and direction in words
def steps_away(dx: int, dy: int) -> int:
return max(abs(dx), abs(dy))
def direction_of(dx: int, dy: int) -> str | None:
step = ((dx > 0) - (dx < 0), (dy > 0) - (dy < 0))
for name, offset in DIRECTIONS.items():
if offset == step:
return name
return None
def phrase(dx: int, dy: int) -> str:
away = steps_away(dx, dy)
return f"{away} cell{'' if away == 1 else 's'} to the {direction_of(dx, dy)}"
steps_away measures in steps, not as the crow flies: a diagonal step gets as
far as a straight one, so the larger of the two distances counts.
phrase makes sentences like 2 cells to the north-east. Always relative,
never absolute.
The ant learns what lies around it — not where on the board it stands. A coordinate would be a piece of data it can do nothing with.
The field of vision
VISION_RADIUS = 3
def vision(level: dict, ant: dict) -> list[str]:
seen = []
for x, y in st.session_state.fruit:
dx, dy = x - ant["x"], y - ant["y"]
if steps_away(dx, dy) <= VISION_RADIUS:
seen.append(f"a fruit, {phrase(dx, dy)}")
for other in st.session_state.ants:
dx, dy = other["x"] - ant["x"], other["y"] - ant["y"]
if other is not ant and steps_away(dx, dy) <= VISION_RADIUS:
seen.append(f"another ant, {phrase(dx, dy)}")
if ant["carrying"]:
nx, ny = nearest_nest_cell(level, ant)
near = steps_away(nx - ant["x"], ny - ant["y"])
if 0 < near <= VISION_RADIUS:
seen.append(f"the nest, {phrase(nx - ant['x'], ny - ant['y'])}")
... # and the trail, further down
return seen
The ant sees three squares in every direction, and that is not much — on purpose. Later (chapter 6) the queen overlooks the whole board and the single ant does not. That difference is what makes the queen's orders valuable later.

The nest is named when it is within sight, and only to an ant that is carrying something. Further down this is complemented by what real ants really have: a scent trail.
And in the state:
"the ant can see": vision(level, ant) or ["nothing at all"],
The options say something now
Until now the options for the answer were a list of names. Now every direction gets a description — when there is something to describe:
def options(level: dict, ant: dict, taken: set[tuple[int, int]]) -> dict[str, str | None]:
ways = free_directions(level, ant, taken)
towards: dict[str, str] = {}
if ant["carrying"]:
nx, ny = nearest_nest_cell(level, ant)
near = steps_away(nx - ant["x"], ny - ant["y"])
home = direction_of(nx - ant["x"], ny - ant["y"])
if home and 0 < near <= VISION_RADIUS:
towards[home] = f"towards the nest, {phrase(nx - ant['x'], ny - ant['y'])}"
way = uphill(ant)
if way and way[0] not in towards:
towards[way[0]] = f"the smell of the colony gets {describe_scent(way[1])}er"
else:
visible = [
(steps_away(x - ant["x"], y - ant["y"]), x, y)
for x, y in st.session_state.fruit
if steps_away(x - ant["x"], y - ant["y"]) <= VISION_RADIUS
]
for _, x, y in sorted(visible):
way = direction_of(x - ant["x"], y - ant["y"])
if way:
towards.setdefault(way, f"towards a fruit, {phrase(...)}")
return {way: towards.get(way) for way in ways}
The keys are the open directions, as they have been since chapter 2. The values say where going that way would lead, and they are made anew every round from what this ant sees. A direction in which nothing interesting lies gets no description — that is honest, there is nothing to say about it.
Remember dict.fromkeys(ways) from chapter 2: there was None everywhere. Now
there are sentences. Nothing about the type has changed.
A real example
This was recorded from a running game, round 5. The ant sees a fruit and a colleague:
"the ant can see": [
"a fruit, 3 cells to the north-west",
"another ant, 2 cells to the south-west"
]
And this is what it gets to choose from:
{
"north": null,
"north-east": null,
"east": null,
"south-east": null,
"south": null,
"south-west": null,
"west": null,
"north-west": "towards a fruit, 3 cells to the north-west"
}
Eight directions, one of them labelled. The other seven stand there without a comment, because there is nothing to say about them — and that is exactly the information: there is only one reason, and it points north-west.
The same ant two rounds later, now with a fruit on its back:
"the ant can see": ["a clear smell of the colony, 1 cell to the south-east"]
{ "south-east": "the smell of the colony gets clearer", ... }
A threshold
MIN_CONFIDENCE = 0.22
def accepted(answer: object, offered: dict[str, str | None]) -> str | None:
if isinstance(answer, BaseException):
st.session_state.discarded.append(type(answer).__name__)
return None
step = answer.choices["step"]
st.session_state.tokens += answer.usage.input_tokens
if step.choice not in offered:
st.session_state.discarded.append(f"{step.choice} was never offered")
return None
if step.confidence < MIN_CONFIDENCE:
st.session_state.discarded.append(f"{step.choice} at {step.confidence:.2f}")
return None
return step.choice
Three ways to reject an answer — and all three are counted, not quietly glossed over. An expandable section below the board shows them with the reason.
Expanded, it looks like this — six lines from a real run:
· north-east at 0.18
· north-west at 0.18
· north-east at 0.16
· north at 0.15
· north-east at 0.13
· north at 0.17
All just below the threshold, and all pointing north. That is not chance but the same slight preference of the model that pushed half the colony to the top edge in chapter 4. When nothing speaks for one direction, it comes through — and the threshold catches it.
MIN_CONFIDENCE = 0.22 is measured, not guessed. It is lower than you might
expect, and that is because of what confidence measures: the distance from pure
chance, normalised by the number of options. With eight options, 0.22 is
already a recognisable preference.

The second check — step.choice not in offered — is the safeguard against the
case where the model names something that was never offered. For me it has
never fired; that is exactly the point of the whole construction. But you
check rather than trust.
A rejected answer means: this ant does nothing this round and is asked again next round.
The trail home
Up to here an ant with a fruit only knows where its nest is when the nest is in sight — that is, within three squares out of 384. That is not enough.
Real ants do not only use their eyes, they use a scent trail: they leave a trail behind, and the way back is the direction in which it gets stronger.
# What a searching ant leaves on the cell it just left, before the decay below.
SCENT_DEPOSIT = 1.0
# What is left of that with every step since the ant was last in the nest.
SCENT_STEP_DECAY = 0.88
# Rounds for a trail to fade to half. Evaporation is not decoration: it is how
# the colony forgets a way that leads nowhere any more.
SCENT_HALF_LIFE = 12.0
# Below this a cell counts as unscented, so old trails stop being offered.
SCENT_THRESHOLD = 0.05
Laid while searching, not while carrying
def lay_scent(level: dict, ant: dict) -> None:
if in_nest(level, ant["x"], ant["y"]):
ant["since_nest"] = 0
else:
ant["since_nest"] += 1
if ant["carrying"]:
return
cell = (ant["x"], ant["y"])
amount = SCENT_DEPOSIT * SCENT_STEP_DECAY ** ant["since_nest"]
st.session_state.scent[cell] = max(st.session_state.scent.get(cell, 0.0), amount)

The colony's roads. The closer to the nest, the stronger the scent — which is why uphill leads home.
Only a searching ant marks the ground, and the mark is the weaker the further it is from the nest. That gives the field a shape: the highest value is in the nest. Uphill is homeward.
For this every ant counts its own steps since the nest — since_nest. The
program resets it as soon as the ant is back inside.
The strongest deposit wins, they do not add up
max(...) is better than +=. If the deposits were added up, a hundred weak
trails far out would beat a strong one at the nest. Traffic still counts, just
differently: a path walked often is marked anew often and so survives
evaporation.
Evaporation
def evaporate() -> None:
factor = 0.5 ** (1 / SCENT_HALF_LIFE)
st.session_state.scent = {
cell: strength * factor
for cell, strength in st.session_state.scent.items()
if strength * factor >= SCENT_THRESHOLD
}
This is not cosmetics but the colony's memory: this is how it forgets a path that no longer leads anywhere. And because everything below the threshold drops out, the dictionary stays small — on a 24 × 16 board it is rarely more than eighty squares.
Uphill
def uphill(ant: dict) -> tuple[str, float] | None:
here = st.session_state.scent.get((ant["x"], ant["y"]), 0.0)
best = None
for name, (dx, dy) in DIRECTIONS.items():
strength = st.session_state.scent.get((ant["x"] + dx, ant["y"] + dy), 0.0)
if strength >= SCENT_THRESHOLD and strength > here:
if best is None or strength > best[1]:
best = (name, strength)
return best
On the summit there is nothing further up, and uphill returns None. That is
not a failure: the summit is the nest, and whoever stands there has arrived.
As a sentence, not as a number
def describe_scent(strength: float) -> str:
if strength >= 0.7:
return "strong"
return "clear" if strength >= 0.3 else "faint"
The state then says:
a clear smell of the colony, 1 cell to the north-west
A number like 0.41 would promise a precision the field does not have, and the model reads words better anyway. The same reasoning as for the field of vision: what is said is what the ant notices, not what is in memory.
What it brings
Three versions, the same order, two runs each:
| Rounds to five fruits | |
|---|---|
| Trail | 16 · 17 |
| Shortcut (nest visible from everywhere) | 18 · 16 |
| neither trail nor shortcut | 2 of 5 after 40 rounds — not made |
The trail is as good as perfect information. It catches up exactly what the shortcut simply gave away — only this time the colony earns it itself, square by square.
And the third line shows that something really happens here: without either, the colony does not reach the goal.
The question says more now
The text has changed along with it:
An ant of the colony has to decide which way to go next. The colony lives on fruit. An ant with empty hands searches: it walks towards any fruit it can see, and where it sees none it keeps going into open ground, away from the nest and away from the other ants, because ground another ant is already walking has nothing left on it. An ant carrying a fruit takes it to the nest. Which way should this ant go now?
Before, it only said what the colony does as a whole. Now it says what an ant with empty hands is to do.
Try it
-
Watch a single ant when a fruit comes into its field of vision.
-
Expand the discarded answers. Who discards — the ants in the crowd or the ones out in the open?
-
Set
VISION_RADIUSto 1. The colony gets much worse, and the number of discards goes up. -
Set
VISION_RADIUSto 8. Does it get better? And what happens to the token count? -
Set
MIN_CONFIDENCEto 0.0: now every answer is accepted, even the guessed ones. Does the colony get faster or slower? -
Take the sentence about fanning out out of
STEP_QUESTIONand start again. It is worth having seen this once yourself. -
Watch the board turn green: the trail grows most where many ants walk, and the brightest spot is in the nest.
-
Set
SCENT_HALF_LIFEto 2 rounds. The trail evaporates before anyone can use it — and the colony is as helpless as in the third line of the table above. -
In
lay_scent, swap themax(...)for+=. The deposits then add up, the summit moves out of the nest to where there is the most traffic — and the carriers walk in the wrong direction. That is the most instructive mistake you can build into this program.
This tutorial was created by Gerhard Völkl using Claude Code (Anthropic). It is meant to help you learn more about what happens when ants ask a decision model. Suggestions for improvement are welcome at gkvoelkl@nelson-games.de.