Objects at the Command Line #004 — Conway's Game of Life Has Two Rules
Conway’s Game of Life is the poster child for emergent complexity: worlds of behavior falling out of a rule you can say in one breath.
- A live cell with 2 or 3 live neighbours survives.
- A dead cell with exactly 3 live neighbours comes alive.
- Everything else dies or stays dead.
That’s the entire game. So here’s a fair question: if the rules are that small, why does a real implementation get so big? Let’s write it in Bash and find out.
The Bash version
Bash has no concept of a grid. You get a flat array and your own arithmetic. Here’s the heart of it (the full script runs a blinker for three generations):
idx() { echo $(( $1 * W + $2 )); }
alive() { # row col -> 1 if live and in-bounds, else 0
local r=$1 c=$2
(( r < 0 || r >= H || c < 0 || c >= W )) && { echo 0; return; }
echo "${grid[$(idx "$r" "$c")]}"
}
neighbours() { # row col -> live-neighbour count
local r=$1 c=$2 n=0 dr dc
for dr in -1 0 1; do
for dc in -1 0 1; do
(( dr == 0 && dc == 0 )) && continue
(( n += $(alive $((r + dr)) $((c + dc))) ))
done
done
echo "$n"
}
step() {
local -a new=()
for (( r = 0; r < H; r++ )); do
for (( c = 0; c < W; c++ )); do
cur=${grid[$(idx "$r" "$c")]}
n=$(neighbours "$r" "$c")
if (( cur == 1 )); then
(( n == 2 || n == 3 )) && new+=(1) || new+=(0)
else
(( n == 3 )) && new+=(1) || new+=(0)
fi
done
done
grid=("${new[@]}")
}
It works — it prints a clean oscillating blinker. But look at the ratio. The two rules live
inside step, and they’re outnumbered ten-to-one by machinery: an idx helper to fake 2D
indexing, manual bounds checks, a $(( r * W + c )) here and an $((r + dr)) there. The
problem is small; the plumbing is most of the file.
The Smalltalk version
Now the grid is an object. Give it two small pieces of vocabulary — “is this cell live?” and “how many live neighbours?” — and watch what happens to the rules.
Object subclass: Grid [
| h w cells |
liveAt: r at: c [
((r between: 1 and: h) and: [c between: 1 and: w]) ifFalse: [ ^false ].
^(cells at: r) at: c
]
neighboursAt: r at: c [
| offsets |
offsets := #( (-1 -1) (-1 0) (-1 1)
(0 -1) (0 1)
(1 -1) (1 0) (1 1) ).
^(offsets select: [:o | self liveAt: r + (o at: 1) at: c + (o at: 2) ]) size
]
survivesAt: r at: c [
| n |
n := self neighboursAt: r at: c.
(self liveAt: r at: c) ifTrue: [ ^(n = 2) or: [ n = 3 ] ].
^n = 3
]
next [
| g |
g := Grid new.
g setCells: ((1 to: h) collect: [:r |
(1 to: w) collect: [:c | self survivesAt: r at: c ]]).
^g
]
]
Run it (gst after.st) and you get the exact same blinker as the Bash version, byte for byte.
Read survivesAt: again
(self liveAt: r at: c) ifTrue: [ ^(n = 2) or: [ n = 3 ] ].
^n = 3
That’s not like the rules — it basically is the rules, transcribed. “A live cell with
2 or 3 neighbours survives; otherwise a cell with exactly 3 comes alive.” The bounds
arithmetic and the wrap-vs-no-wrap decision didn’t disappear — they moved inside liveAt:,
a method with a name that tells you what it’s for. neighboursAt: counts by asking the grid
eight questions, not by juggling offsets in the main loop.
The transferable lesson
You do not need Smalltalk for this. The insight is language-agnostic: when an algorithm is drowning in bookkeeping, the fix is usually a missing object, not a cleverer algorithm. The Bash version isn’t bad Bash — it’s a domain (a grid) that has nowhere to live, so it gets smeared across a flat array and a pile of index math. Give the domain a name and the incidental complexity hides behind it, leaving code that reads like the problem statement.
You’ll recognize the same smell far from Game of Life: the moment you’re passing grid,
width, and height into every function in a Python or JS module, there’s an object in
there asking to be born.
Honest caveat: Bash is genuinely the wrong tool for a 2D simulation, and that’s the point rather than a cheap shot — I picked it because the missing-object pain is so visible there. In a language with real data structures the plumbing is smaller, but the lesson is the same: name the domain and the rules float back to the top.
Every sample — Bash and Smalltalk alike — is executed and diffed to confirm byte-identical output before it ships. I write these in VS Code using my open-source GNU Smalltalk extension. What’s the most bookkeeping-buried piece of code you’ve fought lately?