Conway's game of life in pure pickle for fun and profit
In this post, I wanted to see how far I could push pickle, a serialization library in Python’s standard library that turns out to be a stack-based programming language. By the end of this post, we’ll have implemented Conway’s Game of Life in it, without writing a single line of Python! Let’s start from the basics. This is the hello world program:
hello.pickle
cbuiltins
print
(VHello, world!
tR.
We can run it by unpickling:
> python -c "import pickle,sys; pickle.load(open(sys.argv[1], 'rb'))" hello.pickle
Hello, world!Pickle operates on a stack. It has a number of opcodes that you can look up by reading the source code. We’ll just be using a few of them. This program starts with the statement:
cbuiltins
print
This pushes the function print from module builtins on the top of the stack. The next statement is:
(VHello, world!
The open parentheses ( pushes a “special markobject” onto the stack. VHello, world! simply pushes the string "Hello, world!" on the stack.
The stack now has three elements: builtins.print, (, "Hello, world!".
Now comes the payoff:
tR.
t makes a tuple from everything until the special markobject — in this case, a one-element tuple containing the string — and pushes that back onto the stack. R executes print on the arguments, printing the string. The period ends our program.
Loopy Programs
Pickle doesn’t support writing loops, conditional statements, lambdas, function definitions, or anything else that needs a Python keyword. Interestingly, that doesn’t hold us back from writing a program that prints Hello, world! forever, because you can just import and use the whole standard library:
helloforever.pickle
ccollections
deque
(cbuiltins
map
(cbuiltins
print
citertools
repeat
(VHello, world!
tRtRI0
tR.
This is equivalent to the following Python code:
from collections import deque
from itertools import repeat
deque(map(print, repeat("Hello, world!")), 0)The repeat statement creates an infinite sequence of Hello, world! statements, the map statement pushes those into print, and the deque around it forces that map statement to eagerly evaluate.
Boring Detour: Exec’ing Python
At this point, you might have guessed that we can evaluate arbitrary Python code in a pickle:
hello2.pickle
cbuiltins
exec
(Vprint("Hello, world!")
tR.
Boring! I want to code in pickles. Back to it.
Generating the Fibonacci Sequence
We are ready to code the Fibonacci sequence in pure pickle. We will still have to learn a couple of tricks to get to the Game of Life. Fibonacci’s core step is (a, b) -> (b, a+b). We represent the state as a collections.deque, a double-ended queue with maxlen=2. When a bounded deque is full and you append a new element, the oldest element is automatically dropped from the front:
>>> d = deque([3, 5], maxlen=2)
>>> d.append(8) # 3 is dropped
>>> d
deque([5, 8], maxlen=2)So d.append(sum(d)) is one complete Fibonacci step: sum(d) computes a+b, the append drops a, and we’re left with [b, a+b]. We still need to run print(d[0]) and d.append(sum(d)) on every step without a lambda. The trick is to express both as map pipelines and zip them together:
from collections import deque
from itertools import repeat
from operator import itemgetter
d = deque([0, 1], maxlen=2)
deque(zip(
map(print, map(itemgetter(0), repeat(d))),
map(d.append, map(sum, repeat(d)))
), 0)This program is really just a disguised imperative program. It’s a zip of two infinite maps. We are mutating d while these maps are running. zip pulls from its iterators in order, so the print always fires before the deque update for each step. Again, wrapping this in deque(..., 0) forces the lazy iterators to run eagerly.
We can write this in pickle because of something called the Memo System. It’s just a side dictionary that lives alongside the stack for the duration of the program. Two opcodes control it. p<key> stores the current top of the stack in the memo under <key>, leaving it on the stack. g<key> pushes the value stored at <key> back onto the stack.
Here, we need d in three separate places, so we save it to the memo with p0, then pop it from the stack with 0, and retrieve it with g0 wherever needed. We obtain this pickle:
fibs.pickle
ccollections
deque
((I0
I1
lI2
tRp0
0ccollections
deque
(cbuiltins
zip
(cbuiltins
map
(cbuiltins
print
cbuiltins
map
(coperator
itemgetter
(I0
tRcitertools
repeat
(g0
tRtRtRcbuiltins
map
(cbuiltins
getattr
(g0
Vappend
tRcbuiltins
map
(cbuiltins
sum
citertools
repeat
(g0
tRtRtRtRI0
tR.
> python -c "import pickle,sys; pickle.load(open(sys.argv[1], 'rb'))" fibs.pickle
0
1
1
2
3
5
8
13
21
34
...
Composing Functions
To implement more complex stuff like Conway’s Game of Life, we need to compose standard library functions in pickles. Sadly, the Python standard library has no compose function! Suppose we want to write a pickle program that first strips and then lower-cases all strings in a list of strings. In Python, we’d write this as:
>>> l = ["HELLO ", " WORLD"]
>>> [x.strip().lower() for x in l]
['hello', 'world']or, like this:
>>> list(map(lambda x: x.strip().lower(), l))
['hello', 'world']But in Pickle, we don’t have access to lambdas or list comprehensions, and we cannot define new functions. Suppose we do have access to this function:
def pipe(acc, f):
return f(acc)Then, we can write the snippet as follows:
>>> list(reduce(pipe, [partial(map, str.strip), partial(map, str.lower)], l))
['hello', 'world']My thought process was that we should be able to get the (vast) Python standard library to do pipe for us. I actually searched for this function for quite a long time. Well, there was an answer: In 2023, StackOverflow user blhsing identified that the signal._int_to_enum function is equivalent to pipe. However, this seems to have changed in recent Python versions, and the function now checks whether the argument is really an int.
Our saviour is socket. The function socket._intenum_converter still does exactly what we want:
def _intenum_converter(value, enum_klass):
"""Convert a numeric family value to an IntEnum member.
If it's not a known member, return the numeric value itself.
"""
try:
return enum_klass(value)
except ValueError:
return valueThus, we can write this as:
>>> from socket import _intenum_converter as pipe
>>> list(reduce(pipe, [partial(map, str.strip), partial(map, str.lower)], l))
['hello', 'world']
The equivalent pickle program (including the definition of l) is then:
compose.pickle
cbuiltins
print
(cbuiltins
list
(cfunctools
reduce
(csocket
_intenum_converter
(cfunctools
partial
(cbuiltins
map
cbuiltins
getattr
(cbuiltins
str
Vstrip
tRtRcfunctools
partial
(cbuiltins
map
cbuiltins
getattr
(cbuiltins
str
Vlower
tRtRl(VHELLO
V WORLD
ltRtRtR.
Conway’s Game of Life, entirely contained within pickle
We now have all the ingredients: the memo for sharing values across the program, zip+deque for side-effectful infinite loops, and reduce(pipe, [...]) for composing standard-library functions without lambdas. Time to use them to build Conway’s Game of Life! We’ll first write this in Python without using additional keywords and then translate to Pickle.
We represent live cells as a frozenset of complex numbers. This is convenient because it’s a bit easier to add those together than tuples. We wrap this set in a single-element list to have a mutable reference to it.
The Game of Life rules are: a dead cell with exactly 3 live neighbours is born; a live cell with 2 or 3 live neighbours survives. We define the set of all neighbour offsets for each cell:
offsets = frozenset({
-1-1j, -1+0j, -1+1j,
0-1j, 0+1j,
1-1j, 1+0j, 1+1j,
})Next, we define two functions.
neighbours generates every neighbour of every live cell with repetition. A position shared by k live cells appears k times.
neighbours = partial(reduce, pipe, [
itemgetter(0), # extract frozenset from single-element state list
partial(product, offsets), # pair every offset with every live cell
partial(starmap, add), # add each (offset, cell) pair → neighbour position
])tentimes generates every live cell ten times over:
tentimes = partial(reduce, pipe, [
itemgetter(0), # extract frozenset from single-element state list
partial(product, range(10)), # pair each cell with 0..9
partial(map, itemgetter(1)), # drop the index
])A collections.Counter takes an iterable and counts how many times an element appears. When we concatenate both streams and count with Counter, a dead cell ends up with a count equal to its number of live neighbours and a live cell gets that count plus 10. So a cell appears in these cases:
- count 3 → dead cell with 3 live neighbours → born!
- count 12 → live cell with 2 live neighbours (2 + 10) → survives!
- count 13 → live cell with 3 live neighbours (3 + 10) → survives!
In all other cases, the cell either dies or isn’t born.
We define fan_out = [[neighbours], [tentimes]]. We can now define next_live, which takes the single-element state list and returns the next frozenset:
next_live = partial(reduce, pipe, [
repeat, # infinite copies of state
partial(zip, fan_out), # pair each pipeline in fan_out with a copy of state
partial(starmap, partial(reduce, pipe)), # run each: neighbours(state), tentimes(state)
chain.from_iterable, # concatenate both streams into one
Counter, # count how often each position appears
dict.items, # (position, count) pairs
partial(filter, pred), # keep only positions whose count is in survive ("pred" defined below)
partial(map, itemgetter(0)), # extract just the positions
frozenset, # collect into the next generation
])Putting it all together into a Python program. I chose to hardcode a glider and not print this in a grid output, as especially the latter is more horrible than computing the Game of Life in the first place. If you want, see this as a challenge!
gol.py
from collections import Counter, deque
from itertools import product, starmap, repeat, chain
from functools import partial, reduce
from operator import add, itemgetter, contains, setitem
from socket import _intenum_converter as pipe
offsets = frozenset({
-1-1j, -1+0j, -1+1j,
0-1j, 0+1j,
1-1j, 1+0j, 1+1j,
})
survive = frozenset({3, 12, 13})
neighbours = partial(reduce, pipe, [
itemgetter(0),
partial(product, offsets),
partial(starmap, add),
])
tentimes = partial(reduce, pipe, [
itemgetter(0),
partial(product, range(10)),
partial(map, itemgetter(1)),
])
fan_out = [[neighbours], [tentimes]]
pred = partial(reduce, pipe, [itemgetter(1), partial(contains, survive)])
next_live = partial(reduce, pipe, [
repeat,
partial(zip, fan_out),
partial(starmap, partial(reduce, pipe)),
chain.from_iterable,
Counter,
dict.items,
partial(filter, pred),
partial(map, itemgetter(0)),
frozenset,
])
glider = frozenset([0+0j, 1+0j, 2+0j, 2+1j, 1+2j])
state = [glider]
deque(zip(
map(print, repeat(state)),
map(partial(setitem, state, 0), map(next_live, repeat(state)))
), 0)Let’s build the pickle, which should be a direct translation from this program. LLMs can do this kind of thing nowadays. It works!
gol.pickle
cfunctools
partial
p0
0cfunctools
reduce
p1
0csocket
_intenum_converter
p2
0g0
(g1
g2
tRp3
0cbuiltins
frozenset
((cbuiltins
complex
(I-1
I-1
tRcbuiltins
complex
(I-1
I0
tRcbuiltins
complex
(I-1
I1
tRcbuiltins
complex
(I0
I-1
tRcbuiltins
complex
(I0
I1
tRcbuiltins
complex
(I1
I-1
tRcbuiltins
complex
(I1
I0
tRcbuiltins
complex
(I1
I1
tRltRp4
0cbuiltins
frozenset
((I3
I12
I13
ltRp5
0g0
(g1
g2
(coperator
itemgetter
(I0
tRg0
(citertools
product
g4
tRg0
(citertools
starmap
coperator
add
tRltRp6
0g0
(g1
g2
(coperator
itemgetter
(I0
tRg0
(citertools
product
cbuiltins
range
(I10
tRtRg0
(cbuiltins
map
coperator
itemgetter
(I1
tRtRltRp7
0((g6
l(g7
llp8
0g0
(g1
g2
(coperator
itemgetter
(I1
tRg0
(coperator
contains
g5
tRltRp9
0g0
(g1
g2
(citertools
repeat
g0
(cbuiltins
zip
g8
tRg0
(citertools
starmap
g3
tRcbuiltins
getattr
(citertools
chain
Vfrom_iterable
tRccollections
Counter
cbuiltins
getattr
(cbuiltins
dict
Vitems
tRg0
(cbuiltins
filter
g9
tRg0
(cbuiltins
map
coperator
itemgetter
(I0
tRtRcbuiltins
frozenset
ltRp10
0cbuiltins
frozenset
((cbuiltins
complex
(I0
I0
tRcbuiltins
complex
(I1
I0
tRcbuiltins
complex
(I2
I0
tRcbuiltins
complex
(I2
I1
tRcbuiltins
complex
(I1
I2
tRltRp11
(g11
lp12
0ccollections
deque
(cbuiltins
zip
(cbuiltins
map
(cbuiltins
print
citertools
repeat
(g12
tRtRcbuiltins
map
(g0
(coperator
setitem
g12
I0
tRcbuiltins
map
(g10
citertools
repeat
(g12
tRtRtRtRI0
tR.
The raw output for the glider’s first four steps looks like this:
> python -c "import pickle,sys; pickle.load(open(sys.argv[1], 'rb'))" gol.pickle
[frozenset({0j, (1+0j), (2+0j), (2+1j), (1+2j)})]
[frozenset({(1+0j), (2+0j), 1j, (2+1j), (1-1j)})]
[frozenset({0j, (2+0j), (2+1j), (1-1j), (2-1j)})]
[frozenset({(2+0j), (3+0j), (1+1j), (1-1j), (2-1j)})]
[frozenset({(3+0j), (2+1j), (1-1j), (2-1j), (3-1j)})]
...
Those are indeed the correct positions of a glider travelling diagonally:
step 0 step 1 step 2 step 3 step 4
····· ····· ····· ····· ·····
··█·· ·█·█· ···█· ·█··· ··█··
···█· ··██· ·█·█· ··██· ···█·
·███· ··█·· ··██· ·██·· ·███·
····· ····· ····· ····· ·····
The Game of Life, entirely contained within a pickle. Nature is beautiful.