Game of Life on a tiling of hats, the aperiodic monotile: a tile switches on by the fraction of its neighbours alive — and one hat in eight can never light up.
---
format: typebulb/v1
name: Game of Hats
---
**code.tsx**
```tsx
import React, { useCallback, useEffect, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
/* ------------------------------------------------------------------ *
* Geometry — affine helpers, ported from Craig Kaplan's hatviz
* (github.com/isohedral/hatviz, geometry.js + hat.js), which implements
* the H/T/P/F metatile substitution from Smith, Myers, Kaplan &
* Goodman-Strauss, "An aperiodic monotile" (2023).
* ------------------------------------------------------------------ */
type Pt = { x: number; y: number }
type Mat = number[]
const hr3 = 0.8660254037844386
const ident: Mat = [1, 0, 0, 0, 1, 0]
const pt = (x: number, y: number): Pt => ({ x, y })
const hexPt = (x: number, y: number): Pt => pt(x + 0.5 * y, hr3 * y)
function inv(T: Mat): Mat {
const det = T[0] * T[4] - T[1] * T[3]
return [
T[4] / det, -T[1] / det, (T[1] * T[5] - T[2] * T[4]) / det,
-T[3] / det, T[0] / det, (T[2] * T[3] - T[0] * T[5]) / det,
]
}
function mul(A: Mat, B: Mat): Mat {
return [
A[0] * B[0] + A[1] * B[3], A[0] * B[1] + A[1] * B[4], A[0] * B[2] + A[1] * B[5] + A[2],
A[3] * B[0] + A[4] * B[3], A[3] * B[1] + A[4] * B[4], A[3] * B[2] + A[4] * B[5] + A[5],
]
}
const padd = (p: Pt, q: Pt): Pt => pt(p.x + q.x, p.y + q.y)
const psub = (p: Pt, q: Pt): Pt => pt(p.x - q.x, p.y - q.y)
function trot(ang: number): Mat {
const c = Math.cos(ang), s = Math.sin(ang)
return [c, -s, 0, s, c, 0]
}
const ttrans = (tx: number, ty: number): Mat => [1, 0, tx, 0, 1, ty]
const rotAbout = (p: Pt, ang: number): Mat =>
mul(ttrans(p.x, p.y), mul(trot(ang), ttrans(-p.x, -p.y)))
const transPt = (M: Mat, P: Pt): Pt =>
pt(M[0] * P.x + M[1] * P.y + M[2], M[3] * P.x + M[4] * P.y + M[5])
// Match the unit interval to segment p->q
const matchSeg = (p: Pt, q: Pt): Mat =>
[q.x - p.x, p.y - q.y, p.x, q.y - p.y, q.x - p.x, p.y]
// Match segment p1->q1 onto segment p2->q2
const matchTwo = (p1: Pt, q1: Pt, p2: Pt, q2: Pt): Mat =>
mul(matchSeg(p2, q2), inv(matchSeg(p1, q1)))
function intersect(p1: Pt, q1: Pt, p2: Pt, q2: Pt): Pt {
const d = (q2.y - p2.y) * (q1.x - p1.x) - (q2.x - p2.x) * (q1.y - p1.y)
const uA = ((q2.x - p2.x) * (p1.y - p2.y) - (q2.y - p2.y) * (p1.x - p2.x)) / d
return pt(p1.x + uA * (q1.x - p1.x), p1.y + uA * (q1.y - p1.y))
}
// The hat itself: 13 vertices on the kite grid, edges of length 1 and sqrt(3).
const hat_outline: Pt[] = [
hexPt(0, 0), hexPt(-1, -1), hexPt(0, -2), hexPt(2, -2),
hexPt(2, -1), hexPt(4, -2), hexPt(5, -1), hexPt(4, 0),
hexPt(3, 0), hexPt(2, 2), hexPt(0, 3), hexPt(0, 2),
hexPt(-1, 2),
]
/* ------------------------------------------------------------------ *
* The substitution system
* ------------------------------------------------------------------ */
type Geom = HatTile | MetaTile
class HatTile {
readonly isHat = true
readonly shape = hat_outline
constructor(readonly label: string) {}
}
class MetaTile {
readonly isHat = false
children: { T: Mat; geom: Geom }[] = []
constructor(public shape: Pt[], public width: number) {}
addChild(T: Mat, geom: Geom) { this.children.push({ T, geom }) }
evalChild(n: number, i: number): Pt {
const ch = this.children[n]
return transPt(ch.T, (ch.geom as MetaTile).shape[i])
}
recentre() {
let cx = 0, cy = 0
for (const p of this.shape) { cx += p.x; cy += p.y }
cx /= this.shape.length; cy /= this.shape.length
const tr = pt(-cx, -cy)
this.shape = this.shape.map(p => padd(p, tr))
const M = ttrans(-cx, -cy)
for (const ch of this.children) ch.T = mul(M, ch.T)
}
}
const H1_hat = new HatTile("H1") // the reflected hat
const H_hat = new HatTile("H")
const T_hat = new HatTile("T")
const P_hat = new HatTile("P")
const F_hat = new HatTile("F")
function initTiles(): MetaTile[] {
const H_outline = [
pt(0, 0), pt(4, 0), pt(4.5, hr3),
pt(2.5, 5 * hr3), pt(1.5, 5 * hr3), pt(-0.5, hr3)]
const H = new MetaTile(H_outline, 2)
H.addChild(matchTwo(hat_outline[5], hat_outline[7], H_outline[5], H_outline[0]), H_hat)
H.addChild(matchTwo(hat_outline[9], hat_outline[11], H_outline[1], H_outline[2]), H_hat)
H.addChild(matchTwo(hat_outline[5], hat_outline[7], H_outline[3], H_outline[4]), H_hat)
H.addChild(mul(ttrans(2.5, hr3), mul([-0.5, -hr3, 0, hr3, -0.5, 0], [0.5, 0, 0, 0, -0.5, 0])), H1_hat)
const T_outline = [pt(0, 0), pt(3, 0), pt(1.5, 3 * hr3)]
const T = new MetaTile(T_outline, 2)
T.addChild([0.5, 0, 0.5, 0, 0.5, hr3], T_hat)
const P_outline = [pt(0, 0), pt(4, 0), pt(3, 2 * hr3), pt(-1, 2 * hr3)]
const P = new MetaTile(P_outline, 2)
P.addChild([0.5, 0, 1.5, 0, 0.5, hr3], P_hat)
P.addChild(mul(ttrans(0, 2 * hr3), mul([0.5, hr3, 0, -hr3, 0.5, 0], [0.5, 0, 0, 0, 0.5, 0])), P_hat)
const F_outline = [pt(0, 0), pt(3, 0), pt(3.5, hr3), pt(3, 2 * hr3), pt(-1, 2 * hr3)]
const F = new MetaTile(F_outline, 2)
F.addChild([0.5, 0, 1.5, 0, 0.5, hr3], F_hat)
F.addChild(mul(ttrans(0, 2 * hr3), mul([0.5, hr3, 0, -hr3, 0.5, 0], [0.5, 0, 0, 0, 0.5, 0])), F_hat)
return [H, T, P, F]
}
type Rule = (string | number)[]
const PATCH_RULES: Rule[] = [
["H"],
[0, 0, "P", 2], [1, 0, "H", 2], [2, 0, "P", 2], [3, 0, "H", 2],
[4, 4, "P", 2], [0, 4, "F", 3], [2, 4, "F", 3], [4, 1, 3, 2, "F", 0],
[8, 3, "H", 0], [9, 2, "P", 0], [10, 2, "H", 0], [11, 4, "P", 2],
[12, 0, "H", 2], [13, 0, "F", 3], [14, 2, "F", 1], [15, 3, "H", 4],
[8, 2, "F", 1], [17, 3, "H", 0], [18, 2, "P", 0], [19, 2, "H", 2],
[20, 4, "F", 3], [20, 0, "P", 2], [22, 0, "H", 2], [23, 4, "F", 3],
[23, 0, "F", 3], [16, 0, "P", 2], [9, 4, 0, 2, "T", 2], [4, 0, "F", 3],
]
function constructPatch(H: MetaTile, T: MetaTile, P: MetaTile, F: MetaTile): MetaTile {
const ret = new MetaTile([], H.width)
const shapes: Record<string, MetaTile> = { H, T, P, F }
for (const r of PATCH_RULES) {
if (r.length === 1) {
ret.addChild(ident, shapes[r[0] as string])
} else if (r.length === 4) {
const ch = ret.children[r[0] as number]
const poly = (ch.geom as MetaTile).shape
const p = transPt(ch.T, poly[((r[1] as number) + 1) % poly.length])
const q = transPt(ch.T, poly[r[1] as number])
const nshp = shapes[r[2] as string]
const npoly = nshp.shape
const i = r[3] as number
ret.addChild(matchTwo(npoly[i], npoly[(i + 1) % npoly.length], p, q), nshp)
} else {
const chP = ret.children[r[0] as number]
const chQ = ret.children[r[2] as number]
const p = transPt(chQ.T, (chQ.geom as MetaTile).shape[r[3] as number])
const q = transPt(chP.T, (chP.geom as MetaTile).shape[r[1] as number])
const nshp = shapes[r[4] as string]
const npoly = nshp.shape
const i = r[5] as number
ret.addChild(matchTwo(npoly[i], npoly[(i + 1) % npoly.length], p, q), nshp)
}
}
return ret
}
function constructMetatiles(patch: MetaTile): MetaTile[] {
const bps1 = patch.evalChild(8, 2)
const bps2 = patch.evalChild(21, 2)
const rbps = transPt(rotAbout(bps1, (-2 * Math.PI) / 3), bps2)
const p72 = patch.evalChild(7, 2)
const p252 = patch.evalChild(25, 2)
const llc = intersect(bps1, rbps, patch.evalChild(6, 2), p72)
let w = psub(patch.evalChild(6, 2), llc)
const new_H_outline = [llc, bps1]
w = transPt(trot(-Math.PI / 3), w)
new_H_outline.push(padd(new_H_outline[1], w))
new_H_outline.push(patch.evalChild(14, 2))
w = transPt(trot(-Math.PI / 3), w)
new_H_outline.push(psub(new_H_outline[3], w))
new_H_outline.push(patch.evalChild(6, 2))
const new_H = new MetaTile(new_H_outline, patch.width * 2)
for (const ch of [0, 9, 16, 27, 26, 6, 1, 8, 10, 15]) {
new_H.addChild(patch.children[ch].T, patch.children[ch].geom)
}
const new_P_outline = [p72, padd(p72, psub(bps1, llc)), bps1, llc]
const new_P = new MetaTile(new_P_outline, patch.width * 2)
for (const ch of [7, 2, 3, 4, 28]) {
new_P.addChild(patch.children[ch].T, patch.children[ch].geom)
}
const new_F_outline = [
bps2, patch.evalChild(24, 2), patch.evalChild(25, 0),
p252, padd(p252, psub(llc, bps1))]
const new_F = new MetaTile(new_F_outline, patch.width * 2)
for (const ch of [21, 20, 22, 23, 24, 25]) {
new_F.addChild(patch.children[ch].T, patch.children[ch].geom)
}
const AAA = new_H_outline[2]
const BBB = padd(new_H_outline[1], psub(new_H_outline[4], new_H_outline[5]))
const CCC = transPt(rotAbout(BBB, -Math.PI / 3), AAA)
const new_T = new MetaTile([BBB, CCC, AAA], patch.width * 2)
new_T.addChild(patch.children[11].T, patch.children[11].geom)
new_H.recentre(); new_P.recentre(); new_F.recentre(); new_T.recentre()
return [new_H, new_T, new_P, new_F]
}
/* ------------------------------------------------------------------ *
* Flattening to a patch of hats, and edge adjacency
* ------------------------------------------------------------------ */
type Lat = { i: number; j: number }
type Hat = { poly: Pt[]; lat: Lat[]; label: string; reflected: boolean }
// Every hat vertex lands exactly on the half-scale triangular lattice
// p = ((i + j/2)/2, j·√3/4) — checked to 3e-13 out to 7,921 tiles — so snapping
// vertices to integers makes all the adjacency arithmetic below exact.
function toLat(p: Pt): Lat {
const j = Math.round((p.y * 2) / hr3)
return { i: Math.round(p.x * 2 - j / 2), j }
}
function flatten(g: Geom, T: Mat, out: Hat[]) {
if (g.isHat) {
const poly = hat_outline.map(p => transPt(T, p))
out.push({
poly, lat: poly.map(toLat), label: (g as HatTile).label,
reflected: T[0] * T[4] - T[1] * T[3] < 0,
})
} else {
for (const ch of (g as MetaTile).children) flatten(ch.geom, mul(T, ch.T), out)
}
}
function buildHats(level: number): Hat[] {
let tiles = initTiles()
for (let i = 1; i < level; i++) tiles = constructMetatiles(constructPatch(tiles[0], tiles[1], tiles[2], tiles[3]))
const out: Hat[] = []
flatten(tiles[0], ident, out) // the H supertile makes the nicest patch
return out
}
type Adjacency = { off: Int32Array; nbr: Int32Array }
type NbrMode = "edge" | "corner"
function inside(poly: Pt[], x: number, y: number): boolean {
let hit = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[i], b = poly[j]
if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit
}
return hit
}
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
/**
* Two hats are neighbours when their boundaries share a segment of positive
* length ("edge"), and optionally also when they merely meet at a point
* ("corner" — the analogue of Life's diagonal neighbours). Exact: every hat
* edge is filed under its supporting lattice line, and overlaps within a line
* are found in integer arithmetic, so there is no epsilon to get wrong.
*/
function buildAdjacency(hats: Hat[], mode: NbrMode): Adjacency {
const sets: Set<number>[] = hats.map(() => new Set<number>())
const add = (a: number, b: number) => { if (a !== b) { sets[a].add(b); sets[b].add(a) } }
type Span = { lo: number; hi: number; t: number }
const lines = new Map<string, Span[]>()
hats.forEach((h, idx) => {
const n = h.lat.length
for (let k = 0; k < n; k++) {
const a = h.lat[k], b = h.lat[(k + 1) % n]
let di = b.i - a.i, dj = b.j - a.j
const g = gcd(Math.abs(di), Math.abs(dj)) || 1
di /= g; dj /= g
if (di < 0 || (di === 0 && dj < 0)) { di = -di; dj = -dj } // canonical direction
const key = di + "," + dj + "," + (di * a.j - dj * a.i) // …plus the line invariant
const t1 = a.i * di + a.j * dj, t2 = b.i * di + b.j * dj
const span = { lo: Math.min(t1, t2), hi: Math.max(t1, t2), t: idx }
const grp = lines.get(key)
if (grp) grp.push(span); else lines.set(key, [span])
}
})
for (const grp of lines.values()) {
grp.sort((p, q) => p.lo - q.lo)
for (let a = 0; a < grp.length; a++)
for (let b = a + 1; b < grp.length && grp[b].lo < grp[a].hi; b++) add(grp[a].t, grp[b].t)
}
if (mode === "corner") {
const verts = new Map<string, number[]>()
hats.forEach((h, idx) => {
for (const v of h.lat) {
const key = v.i + "," + v.j
const g = verts.get(key)
if (g) g.push(idx); else verts.set(key, [idx])
}
})
for (const g of verts.values())
for (let a = 0; a < g.length; a++) for (let b = a + 1; b < g.length; b++) add(g[a], g[b])
}
const off = new Int32Array(hats.length + 1)
let total = 0
for (let i = 0; i < hats.length; i++) { off[i] = total; total += sets[i].size }
off[hats.length] = total
const nbr = new Int32Array(total)
let w = 0
for (let i = 0; i < hats.length; i++) for (const j of sets[i]) nbr[w++] = j
return { off, nbr }
}
/**
* The patch's jagged silhouette, exactly: cut every hat edge into primitive
* lattice steps, keep the steps that only one hat owns, and chain them into a
* loop. This is what the controls flow around via CSS `shape-outside`.
*/
function silhouette(hats: Hat[]): Pt[] {
const key = (a: Lat, b: Lat) => {
const f = a.i < b.i || (a.i === b.i && a.j <= b.j)
const [p, q] = f ? [a, b] : [b, a]
return `${p.i},${p.j}|${q.i},${q.j}`
}
const owners = new Map<string, { a: Lat; b: Lat; n: number }>()
for (const h of hats) {
const n = h.lat.length
for (let k = 0; k < n; k++) {
const a = h.lat[k], b = h.lat[(k + 1) % n]
let di = b.i - a.i, dj = b.j - a.j
const g = gcd(Math.abs(di), Math.abs(dj)) || 1
di /= g; dj /= g
for (let t = 0; t < g; t++) {
const p = { i: a.i + di * t, j: a.j + dj * t }
const q = { i: p.i + di, j: p.j + dj }
const kk = key(p, q)
const e = owners.get(kk)
if (e) e.n++; else owners.set(kk, { a: p, b: q, n: 1 })
}
}
}
// steps owned by one hat are on the rim
const at = new Map<string, Lat[]>()
const vk = (v: Lat) => `${v.i},${v.j}`
for (const e of owners.values()) {
if (e.n !== 1) continue
for (const [p, q] of [[e.a, e.b], [e.b, e.a]] as [Lat, Lat][]) {
const g = at.get(vk(p))
if (g) g.push(q); else at.set(vk(p), [q])
}
}
if (!at.size) return []
// walk the longest closed loop
let best: Lat[] = []
const done = new Set<string>()
for (const startKey of at.keys()) {
if (done.has(startKey)) continue
const loop: Lat[] = []
let cur = at.get(startKey)![0]
let prev: Lat = { i: +startKey.split(",")[0], j: +startKey.split(",")[1] }
loop.push(prev)
done.add(startKey)
for (let guard = 0; guard < 100000; guard++) {
loop.push(cur)
done.add(vk(cur))
const opts = at.get(vk(cur)) || []
const nxt = opts.find(o => o.i !== prev.i || o.j !== prev.j)
if (!nxt) break
prev = cur; cur = nxt
if (vk(cur) === startKey) break
}
if (loop.length > best.length) best = loop
}
// lattice -> world, then drop collinear runs
const pts = best.map(v => pt((v.i + v.j / 2) / 2, (v.j * hr3) / 2))
const out: Pt[] = []
for (let k = 0; k < pts.length; k++) {
const a = pts[(k - 1 + pts.length) % pts.length], b = pts[k], c = pts[(k + 1) % pts.length]
const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
if (Math.abs(cross) > 1e-9) out.push(b)
}
// ...and thin the rim down to something a CSS polygon can carry, keeping the
// jags that matter (Ramer–Douglas–Peucker, tolerance in kite widths)
return simplify(out, 0.55)
}
function simplify(pts: Pt[], tol: number): Pt[] {
if (pts.length < 8) return pts
const keep = new Uint8Array(pts.length)
keep[0] = 1; keep[pts.length - 1] = 1
const stack: [number, number][] = [[0, pts.length - 1]]
while (stack.length) {
const [i0, i1] = stack.pop()!
const a = pts[i0], b = pts[i1]
const dx = b.x - a.x, dy = b.y - a.y
const len = Math.hypot(dx, dy) || 1
let worst = -1, at = -1
for (let k = i0 + 1; k < i1; k++) {
const d = Math.abs((pts[k].x - a.x) * dy - (pts[k].y - a.y) * dx) / len
if (d > worst) { worst = d; at = k }
}
if (worst > tol && at > 0) { keep[at] = 1; stack.push([i0, at], [at, i1]) }
}
return pts.filter((_, k) => keep[k])
}
/* ------------------------------------------------------------------ *
* The world
* ------------------------------------------------------------------ */
type World = {
hats: Hat[]
adj: Adjacency
state: Uint8Array
age: Uint16Array
bounds: { x0: number; y0: number; x1: number; y1: number }
outline: Pt[]
maxN: number
avgN: number
reflTotal: number
reflAlive: number
}
function makeWorld(level: number, mode: NbrMode): World {
const hats = buildHats(level)
const adj = buildAdjacency(hats, mode)
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
for (const h of hats) for (const p of h.poly) {
if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x
if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y
}
let maxN = 0
for (let i = 0; i < hats.length; i++) maxN = Math.max(maxN, adj.off[i + 1] - adj.off[i])
return {
hats, adj,
state: new Uint8Array(hats.length),
age: new Uint16Array(hats.length),
bounds: { x0, y0, x1, y1 },
outline: silhouette(hats),
maxN,
avgN: adj.nbr.length / hats.length,
reflTotal: hats.filter(h => h.reflected).length,
reflAlive: 0,
}
}
function mulberry32(a: number) {
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* The rule thresholds the *fraction* of live neighbours, not an exact count —
* Matt Henderson's formulation for his Penrose-rhomb variant, and the only
* sensible reading here, where a hat has anywhere from 2 to 7 neighbours so
* "3 live neighbours" means quite different things to different tiles.
*/
type Rules = {
fBirth: number // switch on when fraction alive >= this
fSurv: number // stay on when fraction alive >= this
fCrowd: number // ...but off when fraction alive >= this (overcrowding)
}
const mkRule = (fBirth: number, fSurv: number, fCrowd: number): Rules =>
({ fBirth, fSurv, fCrowd })
const PRESETS: { name: string; rule: Rules }[] = [
{ name: "Henderson ⅓ · ⅐ · <½", rule: mkRule(1 / 3, 1 / 7, 1 / 2) },
{ name: "Colonies .35 · .15 · <.6", rule: mkRule(0.35, 0.15, 0.6) },
{ name: "Spreading .30 · .30 · <.55", rule: mkRule(0.3, 0.3, 0.55) },
{ name: "Slow .45 · .25 · <.7", rule: mkRule(0.45, 0.25, 0.7) },
{ name: "Pulse .05 · .60 · <.37", rule: mkRule(0.05, 0.6, 0.37) },
]
function stepWorld(w: World, rules: Rules): number {
const { state, age, adj } = w
const n = state.length
const next = new Uint8Array(n)
let pop = 0
for (let i = 0; i < n; i++) {
let c = 0
for (let k = adj.off[i]; k < adj.off[i + 1]; k++) c += state[adj.nbr[k]]
const deg = adj.off[i + 1] - adj.off[i]
const f = deg > 0 ? c / deg : 0
const alive = f < rules.fCrowd && f >= (state[i] ? rules.fSurv : rules.fBirth)
if (alive) { next[i] = 1; pop++ }
}
for (let i = 0; i < n; i++) {
age[i] = next[i] ? (state[i] ? Math.min(age[i] + 1, 400) : 0) : 0
state[i] = next[i]
}
let r = 0
for (let i = 0; i < n; i++) if (state[i] && w.hats[i].reflected) r++
w.reflAlive = r
return pop
}
function randomize(w: World, density: number, seed: number): number {
const rnd = mulberry32(seed)
let pop = 0
let refl = 0
for (let i = 0; i < w.state.length; i++) {
const a = rnd() < density ? 1 : 0
w.state[i] = a
w.age[i] = 0
pop += a
if (a && w.hats[i].reflected) refl++
}
w.reflAlive = refl
return pop
}
/* ------------------------------------------------------------------ *
* Colours (theme-aware, read from CSS variables)
* ------------------------------------------------------------------ */
type Palette = {
deadA: string; deadB: string; line: string
young: string; mid: string; old: string
mirror: string
}
function readPalette(): Palette {
const cs = getComputedStyle(document.documentElement)
const v = (n: string, fb: string) => (cs.getPropertyValue(n).trim() || fb)
return {
deadA: v("--dead-a", "#efece6"),
deadB: v("--dead-b", "#e5e1d9"),
line: v("--line", "#b9b3a8"),
young: v("--young", "#f7b267"),
mid: v("--mid", "#e8622c"),
old: v("--old", "#a02c5a"),
mirror: v("--mirror", "#2f7d78"),
}
}
/* ------------------------------------------------------------------ *
* UI
* ------------------------------------------------------------------ */
function App() {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
const flowRef = useRef<HTMLDivElement | null>(null)
const shapeRef = useRef<HTMLDivElement | null>(null)
const worldRef = useRef<World | null>(null)
const paletteRef = useRef<Palette>(readPalette())
const dirtyRef = useRef(true)
const rimRef = useRef<{ pts: Pt[]; left: number }>({ pts: [], left: 0 })
const [level, setLevel] = useState(3)
const [nbrMode, setNbrMode] = useState<NbrMode>("edge")
const [markRefl, setMarkRefl] = useState(false)
const [rules, setRules] = useState<Rules>(PRESETS[0].rule)
const [running, setRunning] = useState(false)
const [speed, setSpeed] = useState(8)
const [density, setDensity] = useState(0.32)
const [gen, setGen] = useState(0)
const [pop, setPop] = useState(0)
const [refl, setRefl] = useState({ alive: 0, total: 0 })
const [info, setInfo] = useState({ tiles: 0, avgN: 0, maxN: 0 })
const [building, setBuilding] = useState(true)
const rulesRef = useRef(rules); rulesRef.current = rules
const runRef = useRef(running); runRef.current = running
const speedRef = useRef(speed); speedRef.current = speed
const markRef = useRef(markRefl); markRef.current = markRefl
useEffect(() => { dirtyRef.current = true }, [markRefl])
const worldToScreen = useCallback(() => {
const cvs = canvasRef.current!, w = worldRef.current!
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const W = cvs.width / dpr, H = cvs.height / dpr
const b = w.bounds
const bw = b.x1 - b.x0, bh = b.y1 - b.y0
// Wide: the patch hugs the right edge and the controls flow into the space it
// leaves. Narrow: it goes full-bleed across the top, edge to edge, and the
// controls flow underneath. Everything between is a continuous ramp on t —
// no breakpoint, so the patch never jumps size as the window is dragged.
// t stays 0 for every width that can still hold a column of controls beside
// the patch, then ramps to the full-bleed phone layout between 620 and 440
const t = Math.max(0, Math.min(1, (620 - W) / 180))
const share = 0.58 + 0.42 * t // 58% of the width → all of it
const vShare = 0.94 - 0.40 * t // ...taking less of the height as it does
const pad = 18 - 12 * t
// the flow's scrollbar sits over the canvas, so keep the patch clear of it
const fl = flowRef.current
const sb = fl ? fl.offsetWidth - fl.clientWidth : 0
const s = Math.min(((W - sb) * share - pad * 2) / bw, (H * vShare) / bh)
const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2
const ox = W - sb - (bw * s) / 2 - pad
const oyWide = H / 2, oyNarrow = pad + (bh * s) / 2
const oy = oyWide + (oyNarrow - oyWide) * t
return { s, cx, cy, ox, oy, dpr, W, H, bw, bh, pad, sb }
}, [])
// keep the CSS shape-outside polygon glued to where the patch actually is
const syncShape = useCallback(() => {
const el = shapeRef.current, w = worldRef.current, cvs = canvasRef.current
if (!el || !w || !cvs || !w.outline.length) return
const { s, cx, cy, ox, oy, W, bw, bh, pad, sb } = worldToScreen()
const boxW = Math.min(W - sb, bw * s + pad * 2 + 12)
const left = W - sb - boxW
// the float reaches only as far down as the patch does, so once the patch is
// full-bleed the controls simply carry on underneath it
const boxH = oy + (bh * s) / 2 + 6
el.style.display = "block"
el.style.width = `${Math.round(boxW)}px`
el.style.height = `${Math.round(boxH)}px`
const pts = w.outline.map(p => {
const X = (p.x - cx) * s + ox - left
const Y = (cy - p.y) * s + oy
return `${X.toFixed(1)}px ${Y.toFixed(1)}px`
})
rimRef.current = {
left,
pts: w.outline.map(p => pt((p.x - cx) * s + ox, (cy - p.y) * s + oy)),
}
el.style.shapeOutside = `polygon(${pts.join(",")})`
}, [worldToScreen])
// ---- build the tiling ------------------------------------------------
useEffect(() => {
setBuilding(true)
const id = setTimeout(() => {
const w = makeWorld(level, nbrMode)
worldRef.current = w
const p = randomize(w, density, 12345 + level)
setInfo({ tiles: w.hats.length, avgN: w.avgN, maxN: w.maxN })
setPop(p); setGen(0); setBuilding(false)
setRefl({ alive: w.reflAlive, total: w.reflTotal })
dirtyRef.current = true
requestAnimationFrame(() => syncShape())
}, 0)
return () => clearTimeout(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [level, nbrMode])
// ---- theme ------------------------------------------------------------
useEffect(() => {
const refresh = () => { paletteRef.current = readPalette(); dirtyRef.current = true }
const mo = new MutationObserver(refresh)
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "style", "class"] })
const mq = window.matchMedia("(prefers-color-scheme: dark)")
mq.addEventListener("change", refresh)
return () => { mo.disconnect(); mq.removeEventListener("change", refresh) }
}, [])
// ---- canvas sizing ----------------------------------------------------
useEffect(() => {
const cvs = canvasRef.current, wrap = wrapRef.current
if (!cvs || !wrap) return
const ro = new ResizeObserver(() => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
cvs.width = Math.max(1, Math.round(wrap.clientWidth * dpr))
cvs.height = Math.max(1, Math.round(wrap.clientHeight * dpr))
dirtyRef.current = true
requestAnimationFrame(() => syncShape())
})
ro.observe(wrap)
return () => ro.disconnect()
}, [syncShape])
// ---- draw + tick loop -------------------------------------------------
useEffect(() => {
let raf = 0
let acc = 0
let last = performance.now()
const draw = () => {
const cvs = canvasRef.current, w = worldRef.current
if (!cvs || !w) return
const ctx = cvs.getContext("2d")!
const { s, cx, cy, ox, oy, dpr } = worldToScreen()
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, cvs.width / dpr, cvs.height / dpr)
const pal = paletteRef.current
const showLines = s > 3.5
ctx.lineWidth = Math.min(1.2, s * 0.05)
ctx.strokeStyle = pal.line
ctx.lineJoin = "round"
for (let i = 0; i < w.hats.length; i++) {
const h = w.hats[i]
ctx.beginPath()
for (let k = 0; k < h.poly.length; k++) {
const p = h.poly[k]
const X = (p.x - cx) * s + ox
const Y = (cy - p.y) * s + oy
if (k === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y)
}
ctx.closePath()
if (w.state[i]) {
const a = w.age[i]
ctx.fillStyle = a === 0 ? pal.young : a < 6 ? pal.mid : pal.old
} else {
ctx.fillStyle = h.reflected ? (markRef.current ? pal.mirror : pal.deadB) : pal.deadA
}
ctx.fill()
if (showLines) ctx.stroke()
}
dirtyRef.current = false
}
const frame = (now: number) => {
const dt = now - last
last = now
const w = worldRef.current
if (w && runRef.current) {
acc += dt
const interval = 1000 / speedRef.current
let steps = 0
while (acc >= interval && steps < 4) {
const p = stepWorld(w, rulesRef.current)
acc -= interval; steps++
setPop(p); setGen(g => g + 1)
setRefl({ alive: w.reflAlive, total: w.reflTotal })
dirtyRef.current = true
}
if (acc > 1000) acc = 0
}
if (dirtyRef.current) draw()
raf = requestAnimationFrame(frame)
}
raf = requestAnimationFrame(frame)
return () => cancelAnimationFrame(raf)
}, [worldToScreen])
const doRandomize = useCallback(() => {
const w = worldRef.current; if (!w) return
setPop(randomize(w, density, (Math.random() * 1e9) | 0))
setRefl({ alive: w.reflAlive, total: w.reflTotal })
setGen(0); dirtyRef.current = true
}, [density])
// A click that lands on the tiling itself — not on a control — toggles that
// hat. The flow layer owns the pointer (so a narrow window can scroll), so
// the hit test runs here rather than on the canvas.
const onFlowClick = useCallback((ev: React.MouseEvent) => {
const w = worldRef.current, cvs = canvasRef.current
if (!w || !cvs) return
const el = ev.target as HTMLElement
if (el !== ev.currentTarget && !el.classList.contains("shape")) return
const r = cvs.getBoundingClientRect()
const { s, cx, cy, ox, oy } = worldToScreen()
const x = (ev.clientX - r.left - ox) / s + cx
const y = cy - (ev.clientY - r.top - oy) / s
for (let i = 0; i < w.hats.length; i++) {
if (inside(w.hats[i].poly, x, y)) {
w.state[i] = w.state[i] ? 0 : 1
w.age[i] = 0
if (w.hats[i].reflected) w.reflAlive += w.state[i] ? 1 : -1
setPop(p => p + (w.state[i] ? 1 : -1))
setRefl({ alive: w.reflAlive, total: w.reflTotal })
dirtyRef.current = true
break
}
}
}, [worldToScreen])
// ---- probe handlers (terminal <-> page) -------------------------------
useEffect(() => {
return tb.onMessage((m: any) => {
const w = worldRef.current
if (m === "selftest") {
if (!w) return { ready: false }
const counts: Record<number, number> = {}
for (let i = 0; i < w.hats.length; i++) {
const d = w.adj.off[i + 1] - w.adj.off[i]
counts[d] = (counts[d] || 0) + 1
}
let symmetric = true
for (let i = 0; i < w.hats.length && symmetric; i++) {
for (let k = w.adj.off[i]; k < w.adj.off[i + 1]; k++) {
const j = w.adj.nbr[k]
let found = false
for (let q = w.adj.off[j]; q < w.adj.off[j + 1]; q++) if (w.adj.nbr[q] === i) found = true
if (!found) { symmetric = false; break }
}
}
let reflected = 0
for (const h of w.hats) if (h.reflected) reflected++
return {
ready: true, tiles: w.hats.length, reflected,
avgNeighbours: +w.avgN.toFixed(3), maxNeighbours: w.maxN,
degreeHistogram: counts, symmetric, gen, pop,
}
}
if (m && typeof m === "object" && m.run) {
if (!w) return { ready: false }
randomize(w, m.density ?? 0.32, m.seed ?? 7)
const rule: Rules = m.frac ? mkRule(m.frac[0], m.frac[1], m.frac[2]) : rulesRef.current
const traj: number[] = []
let churn = 0
let prev = w.state.slice()
for (let i = 0; i < m.run; i++) {
traj.push(stepWorld(w, rule))
if (i >= m.run - 20) {
for (let k = 0; k < w.state.length; k++) if (w.state[k] !== prev[k]) churn++
prev = w.state.slice()
}
}
dirtyRef.current = true
setPop(traj[traj.length - 1]); setGen(m.run)
setRefl({ alive: w.reflAlive, total: w.reflTotal })
return {
rule: ruleName(rule),
tiles: w.hats.length, final: traj[traj.length - 1],
mirroredAlive: w.reflAlive, mirroredTotal: w.reflTotal,
churnPerGen: +(churn / Math.min(20, m.run)).toFixed(1),
traj: traj.filter((_, i) => i % Math.max(1, Math.floor(m.run / 12)) === 0),
}
}
if (m && typeof m === "object" && m.set === "rule") {
setRules(r => ({ ...r, ...m.value }))
return { ok: true }
}
// does any control end up on top of the tiling?
if (m === "layoutcheck") {
return layoutReport()
}
// pretend the window is <n> px wide, so the flow can be checked at any
// width without anyone touching the window; 0 restores it
if (m && typeof m === "object" && typeof m.width === "number") {
const app = document.querySelector(".app") as HTMLElement | null
if (!app) return { ok: false }
app.style.width = m.width > 0 ? `${m.width}px` : ""
return new Promise(res => setTimeout(() => {
syncShape()
setTimeout(() => res(layoutReport()), 60)
}, 60))
}
return undefined
})
function layoutReport() {
const rim = rimRef.current.pts
if (!rim.length) return { ready: false }
const inRim = (x: number, y: number) => {
let hit = false
for (let i = 0, j = rim.length - 1; i < rim.length; j = i++) {
const a = rim[i], b = rim[j]
if ((a.y > y) !== (b.y > y) && x < ((b.x - a.x) * (y - a.y)) / (b.y - a.y) + a.x) hit = !hit
}
return hit
}
const flow = document.querySelector(".flow")
const bad: string[] = []
const boxes: string[] = []
let checked = 0
for (const el of Array.from(flow?.children ?? [])) {
if (el.classList.contains("shape") || el.classList.contains("brk")) continue
const r = el.getBoundingClientRect()
if (!r.width || !r.height) continue
checked++
boxes.push(`${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}×${Math.round(r.height)} ` +
(el.textContent || el.tagName).trim().slice(0, 22).replace(/\s+/g, " "))
const corners: [number, number][] = [
[r.left, r.top], [r.right, r.top], [r.left, r.bottom], [r.right, r.bottom]]
if (corners.some(([x, y]) => inRim(x, y)))
bad.push((el.textContent || el.tagName).trim().slice(0, 28))
}
const rimL = Math.min(...rim.map(p => p.x)), rimR = Math.max(...rim.map(p => p.x))
const rimT = Math.min(...rim.map(p => p.y)), rimB = Math.max(...rim.map(p => p.y))
const app = document.querySelector(".app") as HTMLElement
return {
checked, rimPoints: rim.length, overlapping: bad,
appWidth: Math.round(app.getBoundingClientRect().width),
patchSpan: [Math.round(rimL), Math.round(rimR)],
patchRows: [Math.round(rimT), Math.round(rimB)],
boxes,
}
}
}, [gen, pop])
const ruleName = (r: Rules) =>
`on ≥${r.fBirth.toFixed(2)} · stay ≥${r.fSurv.toFixed(2)} · off ≥${r.fCrowd.toFixed(2)}`
return (
<div className="app">
<div className="board" ref={wrapRef}>
<canvas ref={canvasRef} aria-label="hat tiling" role="img" />
{building && <div className="veil">building tiling…</div>}
</div>
<div className="flow" ref={flowRef} onClick={onFlowClick}>
<div className="shape" ref={shapeRef} aria-hidden="true" />
<h1>Game of Hats</h1>
<span className="brk" />
<p className="sub">
{info.tiles.toLocaleString()} hats · {info.avgN.toFixed(1)} neighbours each · an aperiodic
monotile, so the pattern never repeats. Click a hat to switch it on or off.
</p>
<span className="brk" />
<div className="grp">
<button className="primary" aria-label={running ? "pause" : "run"}
onClick={() => setRunning(r => !r)}>{running ? "❚❚" : "▶"}</button>
<button onClick={doRandomize}>randomize</button>
</div>
<div className="grp">
<label className="fld">speed
<input type="range" min={1} max={30} value={speed} onChange={e => setSpeed(+e.target.value)} />
<span className="num">{speed}/s</span>
</label>
<label className="fld">density
<input type="range" min={5} max={80} value={Math.round(density * 100)}
onChange={e => setDensity(+e.target.value / 100)} />
<span className="num">{Math.round(density * 100)}%</span>
</label>
</div>
<span className="brk" />
<div className="grp">
<label className="fld">patch
<select value={level} onChange={e => setLevel(+e.target.value)}>
<option value={3}>169 hats</option>
<option value={4}>1,156 hats</option>
</select>
</label>
<label className="fld">neighbours
<select value={nbrMode} onChange={e => setNbrMode(e.target.value as NbrMode)}>
<option value="edge">share an edge</option>
<option value="corner">edge or corner</option>
</select>
</label>
<button className={"toggle" + (markRefl ? " on" : "")}
onClick={() => setMarkRefl(v => !v)}>mark mirrored hats</button>
</div>
<span className="brk" />
<div className="grp rulegrp">
<label className="fld">preset
<select value="" onChange={e => {
const p = PRESETS.find(p => p.name === e.target.value)
if (p) setRules(p.rule)
}}>
<option value="">{ruleName(rules)}</option>
{PRESETS.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
</select>
</label>
<label className="fld">switches on at
<input type="range" min={0} max={100} value={Math.round(rules.fBirth * 100)}
onChange={e => setRules(r => ({ ...r, fBirth: +e.target.value / 100 }))} />
<span className="num">{(rules.fBirth * 100).toFixed(0)}%</span>
</label>
<label className="fld">stays on at
<input type="range" min={0} max={100} value={Math.round(rules.fSurv * 100)}
onChange={e => setRules(r => ({ ...r, fSurv: +e.target.value / 100 }))} />
<span className="num">{(rules.fSurv * 100).toFixed(0)}%</span>
</label>
<label className="fld">crowded out at
<input type="range" min={0} max={100} value={Math.round(rules.fCrowd * 100)}
onChange={e => setRules(r => ({ ...r, fCrowd: +e.target.value / 100 }))} />
<span className="num">{(rules.fCrowd * 100).toFixed(0)}%</span>
</label>
</div>
<span className="brk" />
<div className="grp statgrp">
<span className="stat">gen <b>{gen}</b> · alive <b>{pop.toLocaleString()}</b></span>
<span className="brk" />
<span className={"stat mirrorstat" + (refl.total && refl.alive === 0 ? " zero" : "")}>
mirrored alive <b>{refl.alive}</b> of {refl.total}
{refl.total > 0 && <span className="exp"> · chance would give ~{Math.round(pop * refl.total / Math.max(1, info.tiles))}</span>}
</span>
</div>
<span className="brk" />
<span className="hint">
{rules.fCrowd <= rules.fSurv
? <><b>Survival is impossible with these numbers</b> — crowded-out sits at or below
stays-on, so every live tile dies every tick and the pattern is rebuilt from births
alone. That is the Seeds family, and it pulses.</>
: rules.fCrowd <= rules.fBirth
? <><b>Nothing can be born</b> — crowded-out sits at or below switches-on.</>
: <>Every mirrored hat has <i>exactly</i> four edge-neighbours, and no quarter lands in
[⅓, ½) — so under these thresholds they can never switch on. Watch the counter, then
let corners count as touching and they come back.</>}
</span>
<span className="brk" />
<span className="legend" role="group" aria-label="legend">
<span><i className="sw young" /> born this tick</span>
<span><i className="sw mid" /> alive 1–5</span>
<span><i className="sw old" /> alive 6+</span>
<span><i className="sw dead" /> dead</span>
{markRefl && <span><i className="sw mirror" /> mirrored hat, dead</span>}
</span>
</div>
</div>
)
}
createRoot(document.getElementById("root")!).render(<App />)
```
**styles.css**
```css
:root {
--dead-a: #efece4;
--dead-b: #e6e2d8;
--line: #b6afa2;
--young: #f9c06a;
--mid: #ea5f2b;
--old: #9c2b5c;
--panel: rgba(127, 127, 127, 0.08);
--edge: rgba(127, 127, 127, 0.35);
--mirror: #6fb3ad;
--chrome: rgba(255, 255, 255, 0.88);
--ink: #ffffff;
--on-ink: #24262b;
--mirror-ink: #0f5f57;
}
@media (prefers-color-scheme: dark) {
:root {
--dead-a: #272c34;
--dead-b: #313742;
--line: #545c69;
--young: #ffd27f;
--mid: #f2703a;
--old: #b8336a;
--mirror: #2c6f6a;
--chrome: rgba(18, 20, 24, 0.88);
--ink: #15171b;
--on-ink: #e9ecf1;
--mirror-ink: #74dcd1;
}
}
html[data-theme="dark"] {
--dead-a: #272c34;
--dead-b: #313742;
--line: #545c69;
--young: #ffd27f;
--mid: #f2703a;
--old: #b8336a;
--mirror: #2c6f6a;
--chrome: rgba(18, 20, 24, 0.88);
--ink: #15171b;
--on-ink: #e9ecf1;
--mirror-ink: #74dcd1;
}
html[data-theme="light"] {
--dead-a: #efece4;
--dead-b: #e6e2d8;
--line: #b6afa2;
--young: #f9c06a;
--mid: #ea5f2b;
--old: #9c2b5c;
--mirror: #6fb3ad;
--chrome: rgba(255, 255, 255, 0.88);
--ink: #ffffff;
--on-ink: #24262b;
--mirror-ink: #0f5f57;
}
.app {
height: 100dvh;
min-height: 520px;
position: relative;
overflow: hidden;
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
color: inherit;
box-sizing: border-box;
}
/* the patch is the page; the controls flow into the space it leaves */
.flow {
position: absolute;
inset: 0;
padding: 22px 20px 28px;
box-sizing: border-box;
/* the canvas takes no input any more, so the flow can own the pointer and
simply scroll when a narrow window can't hold every control */
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
/* reserve the gutter always, so the patch never resizes when the bar appears */
scrollbar-gutter: stable;
scrollbar-width: thin;
}
.shape {
float: right;
shape-margin: 20px;
pointer-events: none;
/* width / height / shape-outside are written from the live geometry */
}
h1 {
font-size: clamp(21px, 3.2vw, 30px);
line-height: 1.05;
margin: 0 0 12px;
letter-spacing: -0.02em;
font-weight: 600;
display: inline-block;
white-space: nowrap;
}
.sub {
margin: 0 0 14px;
opacity: 0.7;
font-size: 13.5px;
display: inline-block;
line-height: 1.5;
}
/* every control is an inline chip, so it packs against the tiling's rim */
.flow button,
.flow .fld,
.flow .stat,
.flow .hint,
.flow .legend { margin: 0 6px 7px 0; vertical-align: middle; }
.flow .hint,
.flow .sub,
.flow h1 { display: inline-block; }
/* ...but in clusters, which move as a unit and drop to the next opening rather
than justifying greedily against the rim */
.grp {
display: inline-block;
vertical-align: top;
max-width: min(330px, 42vw);
margin: 0 10px 4px 0;
}
.grp.rulegrp { max-width: min(250px, 42vw); }
.grp.statgrp { max-width: min(260px, 42vw); }
.mirrorstat { white-space: nowrap; }
.brk { display: block; height: 0; clear: none; }
button {
font: inherit;
font-size: 13px;
line-height: 1.45;
padding: 4px 9px;
border: 1px solid var(--edge);
border-radius: 7px;
background: transparent;
color: inherit;
cursor: pointer;
box-sizing: border-box;
/* one height for every pill: buttons are text-only, the .fld pills are as tall
as the select or slider they wrap, so both get pinned to the taller one */
min-height: 34px;
display: inline-flex;
align-items: center;
}
button:hover { background: var(--panel); }
button.primary {
background: var(--ink);
border-color: var(--edge);
color: var(--on-ink);
font-weight: 600;
min-width: 44px;
justify-content: center;
font-size: 13px;
}
button.primary:hover { background: var(--ink); opacity: 0.85; }
.fld {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
line-height: 1.45;
opacity: 0.9;
box-sizing: border-box;
min-height: 34px;
white-space: nowrap;
}
.fld {
border: 1px solid var(--edge);
border-radius: 7px;
padding: 4px 9px;
background: var(--chrome);
backdrop-filter: blur(3px);
}
.fld input[type="range"] { width: 78px; accent-color: var(--mid); }
.flow button { backdrop-filter: blur(3px); background: var(--chrome); }
.flow button:hover { background: var(--panel); }
.flow .stat, .flow .hint, .flow .legend {
background: var(--chrome);
border-radius: 7px;
padding: 3px 8px;
}
.flow h1, .flow .sub {
text-shadow: 0 0 6px var(--chrome), 0 0 14px var(--chrome), 0 0 22px var(--chrome);
}
.num { font-variant-numeric: tabular-nums; opacity: 0.7; min-width: 34px; }
select {
font: inherit;
font-size: 13px;
padding: 2px 2px 2px 4px;
border: 0; /* the .fld pill already draws the border */
background: transparent;
color: inherit;
cursor: pointer;
}
select:focus-visible { outline: 1px solid currentColor; border-radius: 4px; }
option { background: Canvas; color: CanvasText; }
.stat { font-size: 13.5px; font-variant-numeric: tabular-nums; }
.stat b { font-weight: 600; }
.mirrorstat { color: var(--mirror-ink); opacity: 1; font-weight: 500; }
.mirrorstat.zero b { background: var(--mirror-ink); color: Canvas; padding: 1px 6px; border-radius: 5px; }
.mirrorstat .exp { opacity: 0.8; font-weight: 400; }
.rules { display: grid; gap: 6px; }
.ruleRow { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.rlabel { font-size: 12px; opacity: 0.6; width: 96px; }
.ruleRow input[type="range"] { width: 88px; accent-color: var(--mid); }
.ruleRow .rlabel + input[type="range"] { margin-right: 2px; }
.rulestr { font-size: 12px; opacity: 0.6; font-variant-numeric: tabular-nums; margin-left: 6px; }
.hint { font-size: 13px; opacity: 0.75; max-width: min(44ch, 42vw); line-height: 1.5; }
.toggle.on {
opacity: 1;
background: var(--mid);
border-color: var(--mid);
color: #fff;
font-weight: 600;
}
.board {
position: relative;
position: absolute;
inset: 0;
overflow: hidden;
touch-action: none;
}
.board canvas { display: block; width: 100%; height: 100%; }
.veil {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 13px;
opacity: 0.7;
background: var(--panel);
}
.legend {
display: inline-flex;
flex-wrap: wrap;
gap: 5px 15px;
font-size: 12.5px;
opacity: 0.85;
pointer-events: none;
}
.legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.sw {
width: 11px;
height: 11px;
border-radius: 3px;
border: 1px solid var(--line);
}
.sw.young { background: var(--young); }
.sw.mid { background: var(--mid); }
.sw.old { background: var(--old); }
.sw.dead { background: var(--dead-a); }
.sw.mirror { background: var(--mirror); }
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "Game of Life on a tiling of hats, the aperiodic monotile: a tile switches on by the fraction of its neighbours alive — and one hat in eight can never light up.",
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
}
}
```
**notes.md**
```md
The tiling comes from the H/T/P/F metatile substitution in Smith, Myers, Kaplan &
Goodman-Strauss, "An aperiodic monotile" (2023); the substitution rules and the
hat outline are ported from Craig Kaplan's hatviz (github.com/isohedral/hatviz,
geometry.js + hat.js).
Every hat vertex lands exactly on the half-scale triangular lattice
p = ((i + j/2)/2, j·√3/4) — checked to 3e-13 out to 7,921 tiles — so vertices
are snapped to integers and adjacency is computed in exact integer arithmetic:
hat edges are filed under their supporting lattice line and overlaps found by a
sweep. No epsilon. (An independent epsilon-probe implementation agreed tile for
tile, which is how this one was checked.)
Two rule families, because a hat has 2–7 neighbours and an exact count means
different things to different tiles: Conway-style B/S counts, and thresholds on
the *fraction* of live neighbours (Matt Henderson's formulation for his
Penrose-rhomb variant: on if f < 1/2 and f ≥ 1/3 when off, f ≥ 1/7 when on).
B25/S345 is the default because it sustains structure rather than freezing —
B3/S23 read literally settles into still lifes within ~50 generations.
Probe handlers: `typebulb send <file> selftest --wait` reports tile count,
degree histogram and adjacency symmetry; `send '{"run":80,"birth":[4],"survive":[3,4]}' --wait`
runs a headless trajectory from a seeded random fill (`"frac":[b,s,crowd]` for
the fraction rule). From PowerShell, escape the quotes and keep the payload
space-free or it silently never parses.
`send <file> layoutcheck --wait` is the layout assertion: it point-in-polygon
tests every control's corners against the live silhouette and returns anything
sitting on top of the tiling, plus each cluster's box. That is how the flow
layout was tuned without a screenshot.
## Layout
No library. The patch is drawn full-bleed against the right edge, and a floated
element carries `shape-outside: polygon(...)` built from the patch's *actual*
silhouette — every hat edge cut into primitive lattice steps, the steps only one
hat owns chained into a loop, then Douglas-Peucker'd from ~800 points down to
~200 so a CSS polygon can carry it. The controls are inline clusters that flow
into whatever the rim leaves. The rim is a constraint, not a target: clusters
move as units and drop to the next opening rather than justifying greedily
against the edge, with explicit breaks between them.
## Findings
The count-based Conway family (B/S on an exact neighbour count) was explored and
then cut: on a tiling where degree runs 2–7 an exact count means different things
to different tiles, and everything interesting below lives in the fraction rule.
For the record, B3/S23 read literally is sterile here — still lifes by generation
24, zero oscillators; B34/S345 settles into a period-6 attractor (78 cells at
period 2, three at period 6).
**Mirrored hats are a permanent dead zone under the Henderson thresholds.**
Every reflected hat has *exactly* 4 edge-neighbours — all 1,009 of them in the
7,921-hat patch, no exceptions. Birth needs 1/3 ≤ f < 1/2, and no fraction with
denominator 4 lands in that window (1/4 is below, 2/4 is out), so a mirrored hat
can never switch on. After a few dozen generations they are all dead and stay
dead: the automaton punches out precisely the chirality defects of the tiling —
an aperiodic point set of density 1/(1+φ⁴) = 12.73% (measured 12.72% and
12.74%) — without being told they exist. Switch "touching" to edge-or-corner and
their degree becomes 6 and they come back to life. Turn on "mark mirrored hats"
to watch it.
**Duplicated patterns have a computable shelf life.** Seed the same motif at
every site whose surroundings are congruent to radius R, and the futures stay
identical for a while and then split: R=2 → diverges at gen 1, R=3 → gen 3,
R=4 → gen 10, R=5 → gen 11 (Henderson rule). Since congruence classes shrink as
R grows (48 distinct neighbourhoods at radius 1, 1,637 at radius 5), no two
sites agree at every radius, so every duplicated pattern eventually diverges —
the opposite of a periodic lattice, where a translated copy is identical forever.
**Which dials survived the reduction pass.** `touching` is decisive (mirrored
alive: 0 with edges, 46 with corners). `density` changes the outcome for the
Colonies and Slow presets (extinction vs a full patch) and is inert for
Henderson, Spreading and Pulse — kept for the two. Patch sizes below 1,156 were
cut: at 25 hats a quarter of the tiles are low-degree rim, so the boundary, not
the tiling, drives the result. Step was cut (speed 1/s does it).
```