Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ apeiron/node_modules/
syrinx/node_modules/
oikos/node_modules
oikos/shots/
afterlife/node_modules/
afterlife/shots/
blog.sqlite
irc.sqlite
*.db
Expand Down
15 changes: 15 additions & 0 deletions aethera/api/afterlife.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse

from aethera.utils.templates import templates

router = APIRouter(tags=["afterlife"])


@router.get("/afterlife", response_class=HTMLResponse)
async def afterlife_viewer(request: Request):
return templates.TemplateResponse(
request=request,
name="afterlife/viewer.html",
context={"title": "afterlife | æthera"},
)
17 changes: 17 additions & 0 deletions aethera/api/oikos.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@
["by", "Luxia & Claude Fable 5"]],
"tune": True,
},
{
"id": "afterlife",
"title": "afterlife",
"href": "/afterlife",
"group": "here",
"kind": "screensaver",
"accent": "#ffaf00",
"tagline": "the game of life, after hours",
"about": "Conway's Life on an infinite grid that steers toward its own drama: "
"it names its epochs, stages collisions, greets the famous citizens "
"it recognises, and scores itself as it goes. Press g and it "
"remembers everything. It is a place, not a session: the universe "
"is kept in your browser and resumes where you left it.",
"details": [["kind", "cellular automaton · music"], ["born", "2026-02-07"],
["by", "Luxia & Claude"], ["source", "github.com/LuxiaSL/afterlife"]],
"tune": True,
},
{
"id": "irc",
"title": "irc",
Expand Down
1 change: 1 addition & 0 deletions aethera/api/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def rss_feed(request: Request, session: Session = Depends(get_session)):
("oikos", "weekly", "0.6"), # the home directory (and mIRC)
("apeiron", "monthly", "0.6"),
("syrinx", "monthly", "0.6"),
("afterlife", "monthly", "0.6"),
]


Expand Down
3 changes: 2 additions & 1 deletion aethera/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
load_dotenv()

from aethera.models.base import init_db, get_session
from aethera.api import posts, comments, seo, dreams, chronicle, apeiron, syrinx, irc, irc_admin, oikos, chat
from aethera.api import posts, comments, seo, dreams, chronicle, apeiron, syrinx, afterlife, irc, irc_admin, oikos, chat
from aethera.irc.database import init_irc_db
from aethera.utils.security import SecurityHeadersMiddleware
from aethera.utils.templates import templates
Expand Down Expand Up @@ -68,6 +68,7 @@ async def lifespan(app: FastAPI):
app.include_router(chronicle.router)
app.include_router(apeiron.router)
app.include_router(syrinx.router)
app.include_router(afterlife.router)
app.include_router(irc.router)
app.include_router(irc_admin.router)
app.include_router(oikos.router)
Expand Down
1 change: 1 addition & 0 deletions aethera/static/afterlife/afterlife.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions aethera/static/afterlife/afterlife.js

Large diffs are not rendered by default.

346 changes: 173 additions & 173 deletions aethera/static/oikos/oikos.js

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions aethera/templates/afterlife/viewer.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{% extends "base.html" %}

{% block title %}afterlife | æthera{% endblock %}

{% block meta %}
<meta name="description" content="afterlife — the game of life, after hours. An infinite Game of Life that steers toward its own drama, names its epochs, greets the famous citizens it recognises, and scores itself with generative music.">
<link rel="canonical" href="https://aetherawi.red/afterlife">
<meta property="og:title" content="afterlife | æthera">
<meta property="og:description" content="The game of life, after hours: an infinite cellular automaton with generative music, ghost trails, existential musings, and haunted mode.">
<meta property="og:url" content="https://aetherawi.red/afterlife">
<meta property="og:type" content="website">
<meta property="og:site_name" content="æthera">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="afterlife | æthera">
<meta name="twitter:description" content="The game of life, after hours.">
<meta name="theme-color" content="#0a0a0c">
{% endblock %}

{% block schema %}{% endblock %}

{% block extra_head %}
<link href="/static/afterlife/afterlife.css" rel="stylesheet">
<style>
/* afterlife owns the whole viewport: silence the site chrome on this page */
.video-bg, body > header, body > footer { display: none !important; }
main { max-width: none !important; padding: 0 !important; }
html, body { overflow: hidden; margin: 0; padding: 0; background: #0a0a0c; }
</style>
{% endblock %}

{% block content %}
{#
Keep this skeleton in step with afterlife/index.html (the dev page). It
lives in the template rather than being built by main.ts so the splash
paints before the bundle has parsed.

hx-boost="false" on the way out is load-bearing (see syrinx/viewer.html):
an htmx swap would leave the universe, its AudioContext and its loop
running under the next page. A real navigation also fires pagehide, which
is when the universe is saved.
#}
<noscript><p class="afterlife-noscript">afterlife is a Game of Life that runs in your browser; it needs JavaScript. The original runs in a terminal: <a href="https://github.com/LuxiaSL/afterlife">github.com/LuxiaSL/afterlife</a>.</p></noscript>
<div id="afterlife-root">
<canvas id="term" aria-label="the universe: Conway's Game of Life, drawn as a terminal"></canvas>
<div id="splash" role="button" tabindex="0" aria-label="enter; the music starts with you">
<pre class="logo" aria-hidden="true">
█████╗ ███████╗████████╗███████╗██████╗ ██╗ ██╗███████╗███████╗
██╔══██╗██╔════╝╚══██╔══╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔════╝
███████║█████╗ ██║ █████╗ ██████╔╝██║ ██║█████╗ █████╗
██╔══██║██╔══╝ ██║ ██╔══╝ ██╔══██╗██║ ██║██╔══╝ ██╔══╝
██║ ██║██║ ██║ ███████╗██║ ██║███████╗██║██║ ███████╗
╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚═╝╚═╝ ╚══════╝</pre>
<h1>afterlife</h1>
<p class="sub">the game of life, after hours</p>
<p class="go"><span class="mouse">click to listen <span class="sep">·</span> ? for the keys <span class="sep">·</span> q to leave, if you can</span><span class="touch">tap to listen <span class="sep">·</span> the keys are in the corner</span></p>
</div>
<nav id="corner">
<button id="keys-toggle" type="button" aria-expanded="false" aria-controls="keys">keys</button>
<a id="afterlife-home" href="/" hx-boost="false">æthera</a>
</nav>
<section id="keys" hidden aria-label="controls">
<div class="grid">
<button data-act="quit"><kbd>q</kbd>quit</button>
<button data-act="pause"><kbd>space</kbd>pause / resume</button>
<button data-act="reseed"><kbd>r</kbd>reseed the cosmos</button>
<button data-act="clear"><kbd>c</kbd>clear</button>
<button data-act="faster"><kbd>+</kbd>speed up</button>
<button data-act="slower"><kbd>-</kbd>slow down</button>
<button data-act="home"><kbd>h</kbd>home (auto-camera)</button>
<button data-act="focus"><kbd>f</kbd>auto-focus mode</button>
<button data-act="zoomOut"><kbd>z</kbd>zoom out</button>
<button data-act="zoomIn"><kbd>x</kbd>zoom in</button>
<button data-act="stats"><kbd>s</kbd>stats overlay</button>
<button data-act="record"><kbd>d</kbd>snapshot recording</button>
<button data-act="mute"><kbd>m</kbd>mute / unmute music</button>
<button data-act="style"><kbd>v</kbd>chiptune / ambient</button>
<button data-act="volDown"><kbd>[</kbd>volume down</button>
<button data-act="volUp"><kbd>]</kbd>volume up</button>
<button data-act="haunt" class="haunt"><kbd>g</kbd>haunted mode</button>
<button data-act="statsCsv"><span class="nokey" aria-hidden="true"></span>save life_stats.csv</button>
</div>
<p class="pointer">
<span class="mouse">click: toggle a cell · drag: draw life · right-drag: pan · wheel: zoom · arrows: pan</span>
<span class="touch">tap: toggle a cell · drag: pan · pinch: zoom</span>
</p>
<p class="about">
A web port of <a href="https://github.com/LuxiaSL/afterlife" rel="noopener">afterlife</a>, the terminal screensaver:
an infinite Game of Life that steers toward its own drama, names its epochs, greets the famous citizens it recognises,
and scores itself. The universe is kept in this browser and resumes where you left it.
</p>
</section>
</div>
{% endblock %}

{% block scripts %}
<script src="/static/afterlife/afterlife.js"></script>
{% endblock %}
124 changes: 124 additions & 0 deletions afterlife/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# afterlife

> *the game of life, after hours.* A web port of
> [afterlife](https://github.com/LuxiaSL/afterlife), the terminal screensaver.
> Served at **`/afterlife`**, and on channel 10 in [oikos](../oikos).

Conway's Life on a toroidal world five times the size of the screen. A camera
steers toward the drama. A dramaturge stages collisions and aims gliders at
the citizens it has recognised. A ticker muses. The music scores it all, and
`g` brings back the bug that painted the void magenta.

It is meant to be the same program, not a tribute. The terminal was the
medium, so the page is a terminal: a canvas drawn as a grid of character
cells. Every cell is an upper-half block in the same xterm-256 colours curses
asked for, and the status line keeps to the grid one glyph per column.

## Faithful, and how we know

The engine (`src/engine/`) and the music (`src/music/engine.ts`) are
method-for-method ports of `life.py` and `life_music.py`. Two scripts replay
the Python original and compare the results:

```bash
# from the afterlife checkout (a uv project; scipy is an optional extra there)
cd /path/to/afterlife
uv run --with scipy python /path/to/scripts/parity.py . > /tmp/parity.json
npm run parity -- /tmp/parity.json # "the same universe ✓"

python scripts/parity_music.py /path/to/afterlife > /tmp/music.json
npm run parity-music -- /tmp/music.json # "the same music ✓"
```

`parity.py` refuses to run without scipy: `life.py` quietly turns its census
off without it, and the recording would be of the wrong universe. Recording
takes about two minutes, and so does the replay.

`parity` replays 22 cases, in three kinds:

- **still** (16): `life.py`'s `InfiniteLife` from a seeded genesis with the
dice taken out, so the physics and every read-out are deterministic. Nine
seeds; tiny, absurdly tiny, odd and 1080p terminals; travellers crossing
every seam of the torus; haunted mode; pan, zoom, toggle, focus and home;
and resizes, where a new world adopts the old one.
- **live** (4): the dice stay in. `life.py`'s `random` and `np.random` are
swapped for one mulberry32 stream that the port draws from too. Genesis,
every injection, a garden at generation 5000, and on-cue provokes and
collisions then have to draw the same numbers in the same order. One thing
is pinned: `_find_quiet_spot` orders tied scores by
`np.argpartition`, which is numpy's implementation detail, so the live
cases give it a stable sort, as the port uses.
- **focus** (2): hundreds of small worlds of stamped clumps, for
`auto_focus`'s hotspot (scipy's `uniform_filter` breaks exact ties by its
own rounding, and the port reproduces it) and its percentiles.

Every step is checked: population, pop floor, spread, cycle detection, engine
events, camera, auto-zoom, mood, time dilation, the activity centroid, a
checksum of every age, the smoothed ages and activity, the display maps every
seventh step, the census and its sites every 150, and, in live cases, the
draw count. At the end it checks the final ages, the display maps at all five
zooms, the sparkline, the epoch and auto-focus. All of it matches exactly.
`parity-music` feeds both engines the same scripted evening: a boom, a broken
cycle and its cadence, two style crossfades, noise bursts, and epoch roots.
Every sample matches within float32 rounding (worst |Δ| ≈ 2e-7).

## What changed for the web, and why

- **The universe lives in localStorage** (`afterlife-universe-v1`), not
`universe.npz`. It is saved when you leave (`pagehide`, or the tab being
hidden) and every 5000 generations, and resumed on the next visit with
*the universe remembers generation N*. Only ages are stored, sparsely (the
grid is exactly `age > 0`), because a save must be synchronous on the way
out. oikos reads the same key, so its screen runs *your* universe.
- **Music waits for a gesture.** Browsers only start sound from a click or a
key, so the splash asks for one. Until then the status line shows no music,
just as the terminal did without PyAudio. The engine runs in an
AudioWorklet at 44.1 kHz and renders the same 2048-frame buffers PyAudio
asked for, because its smoothing constants are per buffer. `vite.config.ts`
bundles the worklet with esbuild and inlines it into the page.
- **`q` leaves**: back where you came from, or to æthera. Inside an oikos
screen it can't leave, and the ticker says so; ⏏ is on the other side of the
glass. **`d`** records snapshots as before and downloads `snapshots.jsonl`
when you stop (`life_music_diag.py --replay` reads it). `life_stats.csv` is
kept in memory and downloads from the keys panel.
- **More than a terminal's mouse.** A click still toggles a cell. You can
also drag to draw life, right-drag or shift-drag to pan, and scroll to zoom.
On touch: tap to toggle, drag to pan, pinch to zoom. The **keys** panel
(`?`) lists every key and runs it on click, for phones.
- **Narrow screens get a two-line status.** On a phone the ticker would lose
its room to the stats and fall back to the terminal's compact line, so the
ticker gets a line of its own instead. The sparkline shortens to fit.
- **Resizing keeps the universe**, as `KEY_RESIZE` did: it is adopted,
centred, into the new world.
- Two display caches are now invalidated on pan, zoom and toggle. In the
terminal, a pan or click made while paused didn't show until you unpaused.

## Layout

```
src/engine/ life.ts (InfiniteLife), census, ticker, musings, constants, persist, stats
src/term/ render.ts (the terminal: grid, status line, stats overlay), palette.ts (xterm-256)
src/music/ engine.ts (LifeMusicEngine), worklet.ts, audio.ts (the page's side)
src/main.ts life.py's main(): the loop, keys, pointer, splash, persistence
scripts/ parity checks, bench.ts (life_bench.py), shot.mjs (headless screenshots)
```

The engine and renderer have no DOM dependencies beyond a 2D context, so
oikos imports them directly (`oikos/src/screens/afterlife.ts`).

## Build

Like syrinx, apeiron and oikos, the built bundle is committed. The Docker
image never runs npm, so rebuild and commit whenever `src/` changes. Rebuild
oikos too if `src/engine` or `src/term` changed, since it bundles them.

```bash
npm install
npm run build # → ../aethera/static/afterlife/afterlife.{js,css}
npm run dev # the standalone index.html on :5198
npm run bench # per-frame cost, headless (240×66 terminal by default)
npm run shot -- out/ [url] # splash, lived, stats, haunted, keys, resume, phone
```

`shot` wants Playwright's Chromium. Set `CHROMIUM=/path/to/chrome` if the
downloaded one doesn't match.
Loading
Loading