Your contribution graph, running Conway's Game of Life.
Pure SVG and CSS keyframes — no JavaScript, no GIF, no runtime. Drop one workflow in and it regenerates every day.
The loop holds your real graph, runs Life on it, and cuts back.
Add .github/workflows/contribution-life.yml to your profile repository:
name: contribution-life
on:
schedule: [{ cron: "17 3 * * *" }]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
# required: the publish step below needs to be inside a git checkout
- uses: actions/checkout@v7
- uses: satomasahiro2005/contribution-life@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# everything below is optional; the value shown is the default
# login: octocat # whose graph to render; defaults to the repo owner
# layout: calendar # calendar 53x7 | split 27x7 twice | square 19x19
# rule: B3/S23 # any Life-like B../S.. ; B34/S34 is busier, B36/S23 calmer
# gens: 0 # generations per loop; 0 fits it to how long the board lasts
# seed_level: 0 # lowest contribution level that counts as alive, 1-4; 0 = auto
# color: hybrid # hybrid | density | age | gene
# hold: 5 # frames your real graph is held before Life starts
# fade: 2 # frames the levels below seed_level fade out over
# frame_ms: 150 # milliseconds per frame
# edges: torus # torus | dead | auto (auto tries both, keeps the better)
# out_dir: dist # directory the SVGs are written to
# name: contribution-life # produces NAME.svg and NAME-dark.svg
- name: Publish the SVGs to an orphan branch
run: |
set -euo pipefail
staging=$(mktemp -d) && cp dist/*.svg "$staging/"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout --orphan output
git rm -rf . >/dev/null 2>&1 || true
cp "$staging"/*.svg .
git add ./*.svg
git commit -m "contribution-life $(date -u +%F)"
git push -f origin outputThe actions/checkout step is what people miss: the action itself does not need
it, but git push at the end has nothing to push from without it, and the job
fails with fatal: not in a git directory.
Run it once, then put this in your README:
<picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/USER/REPO/output/contribution-life-dark.svg">
<img alt="Contribution graph running Conway's Game of Life"
src="https://raw.githubusercontent.com/USER/REPO/output/contribution-life.svg">
</picture>Or run it locally — standard library only, nothing to install:
GITHUB_TOKEN=$(gh auth token) python glife.py fetch --login YOUR_NAME
python glife.py renderSeven rows is very thin for Life. layout reshapes the same year of data, and it
changes how long the colony survives more than anything else does.
|
| |
|
|
|
Population of B3/S23 over 100 generations, same seed, edges wrapped:
| layout | shape | median | second half | |
|---|---|---|---|---|
calendar |
53×7 | 9% | 5% | thins out past ~gen 45 |
split |
27×14 | 3% | 3% | thins out faster |
square |
19×19 | 12% | 16% | never collapses, recovers |
gens and seed_level default to auto. Every threshold is actually simulated
and cut once the board empties or its exact state starts repeating; the one that
runs longest wins. Contribution graphs vary enormously and no fixed setting
works for all of them:
| graph | picked | population | |
|---|---|---|---|
| a very quiet year | 6% full | L1+ |
15–35 |
| a quiet year | 13% full | L1+ |
13–51 |
| a normal year | 27% full | L1+ |
11–103 |
| a very busy year | 95% full | L2+ |
24–121 |
That last row is the one that matters. At 95% density every cell has 7 or 8 neighbours, none of which are in S23, so an untouched board dies of overpopulation on the very first step. Raising the threshold thins it back into a range where Life works — and the levels being dropped fade out on screen during the intro, so nothing is quietly removed behind your back.
Repetition is the only signal used for "nothing new is happening", and population deliberately is not. A sparse graph starts near zero, so any measure of how full the board is would cut those runs off immediately however much was still moving. A glider crossing a torus does not repeat until it has come all the way round, so it keeps running.
edges defaults to torus, which is the right call for two of the three
layouts. edges: auto runs both and keeps the better, and it is worth setting
on split:
| layout | torus |
dead |
|
|---|---|---|---|
calendar |
100 gens | 43 gens | the 53×7 strip needs the wrap |
split |
49 gens | 100 gens | the wrap folds the two bands into each other |
square |
100 gens | 100 gens | either works |
The rule engine is plain B3/S23 with simultaneous updates. test_rules.py
checks that against published patterns instead of asking you to trust it:
- five still lifes stay fixed; blinker, toad and beacon have period 2; the pulsar has period 3
- a glider keeps its shape and moves exactly (1,1) every four generations
- r-pentomino and acorn match an independent sparse-set implementation for 150 generations, cell for cell
- diehard vanishes at generation 130, as it must
python test_rules.pyColour is the only thing layered on top, and it never feeds back into birth or death. A coloured cell is always a live cell — dead cells get no fading trail, so what you see is exactly the state of the board.
GitHub's palette has four live levels, and the obvious ways to choose one are worse than they look. Share of each level actually used:
color |
L1 | L2 | L3 | L4 | |
|---|---|---|---|---|---|
age — generations survived |
41% | 22% | 17% | 18% | washed out; most live cells are newborns |
gene — majority vote of the three parents |
84% | 0% | 0% | 14% | collapses, because 75% of a real graph is level 1 |
density — live cells in the surrounding 5×5 |
31% | 21% | 31% | 16% | bright cores, dark fringes |
hybrid — all three |
26% | 30% | 21% | 21% | default |
Whichever metric you pick, its raw values are gathered across every frame and split at their own quartiles, so all four levels stay in use no matter the rule.
rule takes any Life-like B../S.. string. Measured over 120 generations:
| rule | median | churn | |
|---|---|---|---|
B3/S23 |
9% | 8% | Conway, the default |
B36/S23 |
12% | 12% | HighLife; replicators spread into empty regions |
B36/S125 |
13% | 12% | steady, and much livelier on square |
B34/S34 |
39% | 42% | busy, fills the board indefinitely |
B35/S236 |
37% | 37% | busy but structureless |
B368/S238 |
18% | 20% | Day & Night; grows into a blob |
B3/S1234 |
48% | 2% | freezes solid |
python serve.py # http://localhost:8765Type any username, scrub the animation frame by frame, and watch the population
curve and palette usage react. It renders through the same pipeline the action
does, so what you see is what gets published. Fetched calendars are cached under
cache/.
sweep.py regenerates the rule and layout tables above for your own graph, and
filmstrip.py dumps selected frames as ASCII.
| action input | CLI flag | default | |
|---|---|---|---|
github_token |
— | — | GITHUB_TOKEN sees public contributions only |
login |
--login |
repo owner | |
layout |
--layout |
calendar |
calendar / split / square |
rule |
--rule |
B3/S23 |
any B../S.. |
gens |
--gens |
0 |
generations per loop, 0 = auto |
seed_level |
--seed-level |
0 |
minimum level counted as alive, 0 = auto |
color |
--color |
hybrid |
hybrid / density / age / gene |
hold |
--hold |
5 |
frames the real graph is held |
fade |
--fade |
2 |
frames the dropped levels fade over |
frame_ms |
--frame-ms |
150 |
|
edges |
--edges |
torus |
torus / dead / auto |
out_dir / name |
--out / --name |
dist / contribution-life |
README images are served through GitHub's camo proxy and rendered in an <img>
context, so scripts never run. Only CSS animations and SMIL work. Every
generation is therefore simulated ahead of time and written out as keyframes:
unchanging cells become plain static rects, changing cells get one stop per
change, identical timelines are shared, and step-end holds each stop until the
next so generations stay discrete. The intro fade is the one exception — those
keyframes switch to linear so the dropped cells dissolve instead of blinking
out.
A typical loop is around 100 frames and 125 KB, which is about 11 KB over the wire once camo has gzipped it.
v1 is a moving alias for the newest 1.x, and is what the examples above use.
Pin an exact release like @v1.1.0 if you would rather freeze the behaviour —
gens and seed_level are fitted by simulation, so a change to how a run is
cut short can change what your graph looks like.
- A scheduled workflow stops after 60 quiet days. GitHub disables
scheduleon a public repository when nothing has happened there for 60 days, and the daily commit this workflow makes does not count as something happening. You get an email before it goes, and one button in the Actions tab brings it back; running it by hand from time to time also resets the clock. Nothing breaks when it stops — the graph just stays on the last day it rendered. - The default
GITHUB_TOKENonly sees public contributions. For a graph that matches your profile, use a PAT withread:user. - camo caches images; a push to the repository clears it.
prefers-color-schemeinside an<img>follows the OS theme rather than GitHub's own theme setting. That is a limitation of<picture>on GitHub generally, not of this tool.
MIT