Objects at the Command Line #004 — Conway's Game of Life Has Two Rules

August 13, 2026

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?