Game of Hats

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).
```

Markdown source · More bulbs by antypica · Typebulb home