Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f9398cc
fix(a11y): audit 11 finished and 36 — every control a finger can hit,…
PSheon Sep 22, 2026
56af436
docs(cnn): polish № 001 — accuracy, repetition, the list of numbers, …
PSheon Sep 22, 2026
8d535f4
docs(cnn): keep № 001's updated date as it was (2026-09-20)
PSheon Sep 22, 2026
0c18557
docs(flappy): polish № 002 — the eight edits Paul chose
PSheon Sep 22, 2026
684032d
docs(trading): polish № 003 — the nine edits Paul chose, and one numb…
PSheon Sep 22, 2026
307d5a7
docs(transformer): polish № 004 — two corrections, one measured numbe…
PSheon Sep 22, 2026
36eff1a
docs(hydranet): polish № 005 — the ten edits Paul chose
PSheon Sep 22, 2026
7d4bc60
fix(a11y): the last two short touch targets — the home page's view-al…
PSheon Sep 22, 2026
7f09b33
docs(lite3): polish № 006 — ten edits, one number measured
PSheon Sep 22, 2026
708ac26
docs(diffusion): polish № 007 — the six edits Paul chose
PSheon Sep 22, 2026
e307957
docs(slam): polish № 008 — seven edits, the ICP figure's numbers meas…
PSheon Sep 22, 2026
3dd29a1
docs(city): polish № 009 — the three edits Paul chose
PSheon Sep 22, 2026
aa281ce
docs(scheduler): polish № 010 — the three edits Paul chose
PSheon Sep 22, 2026
78d6194
docs(light): polish № 011 — the line count as it is now, and the sour…
PSheon Sep 22, 2026
6173f5e
docs(light-playground): polish № 012 — what changed since it was writ…
PSheon Sep 22, 2026
0140439
docs(head-camera): polish № 013 — two seed scores corrected, "twice" …
PSheon Sep 22, 2026
09dc7f9
docs(research): head-camera run 25, and two measurements that were ne…
PSheon Sep 22, 2026
42ee261
docs(head-camera): the failure table's noise columns as the range of …
PSheon Sep 22, 2026
69543cb
fix(site): thirteen visibility observers read the latest entry, not t…
PSheon Sep 22, 2026
65e16ed
docs(handoff): bring the header up to PR #15, today's tests and branches
PSheon Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default async function HomePage({ params }: PageProps<"/[locale]">) {
</SectionHeading>
<p className="mt-1 text-sm text-muted-foreground">{t.home.indexLead}</p>
</div>
<Link href={`/${locale}/posts`} className="inline-flex min-h-6 items-center text-sm text-signal underline-offset-4 hover:underline">
<Link href={`/${locale}/posts`} className="tap inline-flex min-h-6 items-center text-sm text-signal underline-offset-4 hover:underline">
{t.home.viewAll}
</Link>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/tags/[tag]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default async function TagPage({ params }: PageProps<"/[locale]/tags/[tag
return (
<div className="mx-auto w-full max-w-7xl px-5 sm:px-8">
<PageHeader title={`#${tag}`} lead={t.tags.count(posts.length)}>
<Link href={`/${locale}/tags`} className="mt-4 inline-flex min-h-6 items-center text-sm text-signal underline-offset-4 hover:underline">
<Link href={`/${locale}/tags`} className="tap mt-4 inline-flex min-h-6 items-center text-sm text-signal underline-offset-4 hover:underline">
{t.tags.title}
</Link>
</PageHeader>
Expand Down
18 changes: 12 additions & 6 deletions components/article/back-to-top.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { useReducedMotion } from "@/components/lab/use-reduced-motion";
import { cn } from "@/lib/utils";

/**
* The way back up a long article: a round button in the bottom right corner that appears once the reader has
* scrolled a screen down, and goes again near the top. It floats over the page, so it is one of the few things that
* cast a shadow (DESIGN §Visual). It only fades; a button that slid while the page scrolled read as drifting. Dialogs and the expanded playground are z-50 and cover it; the install hint on a
* phone pushes it up (globals.css). After the jump, focus goes to the title, so the keyboard carries on from the top.
* The way back up a long article: a round button in the bottom right corner. It appears when the reader, a screen or
* more down, scrolls back up (that is when a way to the top is wanted), and gets out of the way while they read down,
* where on a phone it sat over the last line of text. It only fades; a button that slid while the page scrolled read
* as drifting. It floats over the page, so it is one of the few things that cast a shadow (DESIGN §Visual). Dialogs
* and the expanded playground are z-50 and cover it; the install hint on a phone pushes it up (globals.css). After the
* jump, focus goes to the title, so the keyboard carries on from the top.
*/
export function BackToTop({ label }: { label: string }) {
const [shown, setShown] = useState(false), calm = useReducedMotion(), jumping = useRef(false); // on its way up: stay hidden until the top
Expand All @@ -21,8 +23,12 @@ export function BackToTop({ label }: { label: string }) {
const y = window.scrollY;
// the glide up ends at the top, or the moment the reader scrolls down again through it
if (jumping.current && y > 0 && y <= lastY) { lastY = y; return; }
jumping.current = false; lastY = y;
setShown(y > window.innerHeight);
jumping.current = false;
// Reading down, it would sit over the last line of text on a phone: it shows only while the reader scrolls back up
// (a few pixels of hysteresis, so a finger's jitter does not flicker it), and never near the top.
const up = y < lastY - 4, down = y > lastY + 4;
if (up || down) lastY = y;
if (y <= window.innerHeight) setShown(false); else if (up) setShown(true); else if (down) setShown(false);
};
const onScroll = () => { if (!queued) queued = requestAnimationFrame(update); };
update();
Expand Down
2 changes: 1 addition & 1 deletion components/article/post-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function PostFooter({ locale, post, newer, older, related, t }: Props) {
href={editUrl(post.slug, post.locale)}
target="_blank"
rel="noreferrer"
className="flex min-h-6 items-center gap-1.5 text-sm text-muted-foreground hover:text-signal"
className="tap flex min-h-6 items-center gap-1.5 text-sm text-muted-foreground hover:text-signal"
>
<Pencil className="size-3.5" aria-hidden />
{t.edit}
Expand Down
2 changes: 1 addition & 1 deletion components/mdx/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function CodeBlock(props: ComponentProps<"pre">) {
type="button"
onClick={copy}
aria-label={copied ? t.copied : t.copy}
className="absolute right-2 top-2 grid size-7 place-items-center rounded-md border border-border bg-background/80 text-muted-foreground transition-opacity before:absolute before:-inset-2 hover:text-foreground focus-visible:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/code:opacity-100"
className="absolute right-2 top-2 grid size-7 place-items-center rounded-md border border-border bg-background/80 text-muted-foreground transition-opacity before:absolute before:-inset-[9px] hover:text-foreground focus-visible:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/code:opacity-100"
>
{copied ? <Check className="size-3.5 text-signal" /> : <Copy className="size-3.5" />}
</button>
Expand Down
2 changes: 1 addition & 1 deletion components/rt/use-tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function useTracer(root: RefObject<HTMLElement | null>, canvas: RefObject

useEffect(() => {
let alive = true, visible = false;
const io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), { rootMargin: "200px" });
const io = new IntersectionObserver((entries, _observer, entry = entries[entries.length - 1]) => (visible = entry.isIntersecting), { rootMargin: "200px" });
if (root.current) io.observe(root.current);
const worker = new Worker(new URL("./scene.worker.ts", import.meta.url), { type: "module" });
const frame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
Expand Down
2 changes: 1 addition & 1 deletion components/site/diffusion-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export default function DiffusionPreview({ className = "block aspect-[8/5] w-ful
if (want && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
else if (!want && raf) { cancelAnimationFrame(raf); raf = 0; }
};
const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; run(); });
const io = new IntersectionObserver((entries, _observer, e = entries[entries.length - 1]) => { visible = e.isIntersecting; run(); });
io.observe(canvas);
document.addEventListener("visibilitychange", run);
draw(0, 0);
Expand Down
2 changes: 1 addition & 1 deletion components/site/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function SiteFooter({ locale, t }: { locale: Locale; t: Dictionary }) {
<div className="relative mx-auto w-full max-w-7xl px-5 pt-24 pb-6 sm:px-8">
<div className="grid gap-x-12 gap-y-8 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
<div className="grid gap-5">
<Link href={`/${locale}`} className="flex min-h-6 w-fit items-center gap-2.5">
<Link href={`/${locale}`} className="tap flex min-h-6 w-fit items-center gap-2.5">
<KernelMark className="size-4" />
<span className="font-mono text-sm">{site.name}</span>
</Link>
Expand Down
2 changes: 1 addition & 1 deletion components/site/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function SiteHeader({ locale, t, tags }: Props) {
{ href: `/${locale}/tags`, label: t.nav.tags },
];
const logo = (
<Link href={`/${locale}`} className="flex min-h-6 items-center gap-2.5" aria-label={`${site.name} — ${t.nav.home}`}>
<Link href={`/${locale}`} className="tap flex min-h-6 items-center gap-2.5" aria-label={`${site.name} — ${t.nav.home}`}>
<KernelMark className="size-4" />
<span className="font-mono text-sm font-medium tracking-tight">{site.name}</span>
</Link>
Expand Down
2 changes: 1 addition & 1 deletion components/site/hero-stations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function HeroStations({ stations, label, hint, t }: Props) {
// wide fallback serif (Linux, some Android) did not fit at 14 px. The dot goes first, under 24rem.
// The chosen station is told by its colour: text in full ink and a 2 px rule in the station's own colour under it
// (the tabs sit in a title bar, where a boxed pill looked like a stray button). Not colour alone: the rule is a shape too.
"relative flex min-h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-sm px-0.5 font-heading text-[clamp(0.6875rem,3.5cqw,0.875rem)] font-semibold whitespace-nowrap transition-colors @[30rem]:px-2 @[30rem]:text-sm",
"tap relative flex min-h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-sm px-0.5 font-heading text-[clamp(0.6875rem,3.5cqw,0.875rem)] font-semibold whitespace-nowrap transition-colors @[30rem]:px-2 @[30rem]:text-sm",
"after:absolute after:inset-x-1 after:-bottom-px after:h-0.5 after:rounded-full after:bg-[var(--station)] after:opacity-0 after:transition-opacity after:content-['']",
s.key === active ? "text-foreground after:opacity-100" : "text-muted-foreground hover:text-foreground",
)}
Expand Down
2 changes: 1 addition & 1 deletion components/site/number-ticker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function NumberTicker({ value, pad = 0, duration = 900 }: { value: number
setShown(Math.round(value * eased));
if (t < 1) raf = requestAnimationFrame(tick);
};
const seen = new IntersectionObserver(([entry]) => {
const seen = new IntersectionObserver((entries, _observer, entry = entries[entries.length - 1]) => {
if (!entry.isIntersecting) return;
seen.disconnect();
setShown(0);
Expand Down
2 changes: 1 addition & 1 deletion components/ui/slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function Slider({
data-slot="slider-thumb"
key={index}
getAriaValueText={valueText === undefined ? undefined : () => valueText}
className="relative block size-3 shrink-0 cursor-grab rounded-full active:cursor-grabbing border border-ring bg-white ring-ring/50 focus-visible:ring-ring transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
className="relative block size-3 shrink-0 cursor-grab rounded-full active:cursor-grabbing border border-ring bg-white ring-ring/50 focus-visible:ring-ring transition-[color,box-shadow] select-none after:absolute after:-inset-2 pointer-coarse:after:-inset-[17px] hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
Expand Down
4 changes: 2 additions & 2 deletions content/posts/ai-flappy-bird/components/flappy-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function FlappyLab() {

// Don't burn a core on an instrument nobody is looking at. Watch the whole instrument,
// not the canvas: on a phone the controls and readouts sit a screen below it.
const io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting));
const io = new IntersectionObserver((entries, _observer, entry = entries[entries.length - 1]) => (visible = entry.isIntersecting));
io.observe(rootRef.current ?? canvas);
paint();
if (playing) frame = requestAnimationFrame(loop);
Expand Down Expand Up @@ -168,7 +168,7 @@ export function FlappyLab() {
setSpeed(s);
}}
className={cn(
"h-7 min-w-9 rounded-sm border px-1.5 font-mono text-xs transition-colors",
"tap h-7 min-w-9 rounded-sm border px-1.5 font-mono text-xs transition-colors",
speed === s ? "border-signal bg-signal/10 text-signal" : "border-border text-muted-foreground hover:text-foreground",
)}
>
Expand Down
16 changes: 8 additions & 8 deletions content/posts/ai-flappy-bird/en.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "From rookie to boss bird: 50 birds teach themselves Flappy Bird"
description: Give each of fifty birds a brain of only six weights, add survival of the fittest, and usually within twenty-odd generations they clear a hundred pipes in a row. Then open that brain and see what it actually learned.
description: Give each of fifty birds a brain of only six weights, add survival of the fittest, and usually within a dozen or so generations they clear a hundred pipes in a row. Then open that brain and see what it actually learned.
seoTitle: "50 birds teach themselves Flappy Bird"
seoDescription: "Give each of fifty birds a brain of six weights, add survival of the fittest, and within twenty-odd generations they clear a hundred pipes. Then open the brain."
seoDescription: "Give each of fifty birds a brain of six weights, add survival of the fittest, and in a dozen or so generations they clear a hundred pipes. Then open the brain."
date: 2025-03-15
updated: 2026-09-20
tags: [ai-agent, neuroevolution, from-scratch]
Expand All @@ -22,13 +22,13 @@ The fifty birds below are evolving in your browser right now. Watch them hit the

## A brain of six numbers

The drawing on the right of the instrument is everything a bird has:
The brain on the right of the figure above is everything a bird has:

- **Two inputs**: the bird's height and the height of the next opening.
- **Two hidden neurons.**
- **One output**: above 0.5, the bird flaps.

The network has no biases, so its connections carry six weights in all: 2 × 2 + 2 × 1. Putting a neural network and evolution together is called neuroevolution. Instead of computing how each weight should change, you keep a crowd of individuals with different weights and let the ones that do well leave offspring.
The network has no biases, so its connections carry six weights in all: 2 × 2 + 2 × 1. Putting a neural network and evolution together is called neuroevolution.

## How one generation becomes the next

Expand Down Expand Up @@ -66,15 +66,15 @@ Those three pieces of code are the whole algorithm.<Sidenote>The same `Populatio

## Open the brain: what it learned is a subtraction

Once the birds fly well, look at the drawing on the right of the instrument. Brains that evolved successfully tend to look alike: at least one hidden neuron receives "bird height" and "gap height" through lines of **opposite colours**, one positive and one negative.
Once the birds fly well, look at the brain on the right of the figure. Brains that evolved successfully tend to look alike: at least one hidden neuron receives "bird height" and "gap height" through lines of **opposite colours**, one positive and one negative.

One positive plus one negative computes "my height minus the height of the opening": am I above the opening or below it? Below, so flap. This is no coincidence. Both inputs are positive numbers between 0 and 1, so a neuron whose two weights share a sign can only measure how large the two are together; it cannot tell which is higher. Comparing them takes one positive and one negative.<Sidenote>I ran 40 random seeds for 60 generations each, and 35 learned. Of those 35 graduating birds, 33 have at least one hidden neuron like this. Take it with a pinch of salt: six purely random weights meet the condition 75% of the time by chance. The script and its output are in `docs/research/evolution-seeds/`.</Sidenote>
One positive plus one negative computes "my height minus the height of the opening": am I above the opening or below it? Below, so flap. This is no coincidence. Both inputs are positive numbers between 0 and 1. If the two weights share a sign, then when the bird and the opening both rise, the neuron's output only grows with them: what it measures is how high the two are together, not which is higher. Comparing them takes one positive and one negative.<Sidenote>I ran 40 random seeds for 60 generations each, and 35 learned. Of those 35 graduating birds, 33 have at least one hidden neuron like this. Take it with a pinch of salt: the starting weights are uniform between −1 and 1, so each hidden neuron has an even chance of one positive and one negative; both missing it has a chance of ¼, and a random brain meets the condition 1 − ¼ = 75% of the time. The script and its output are in `docs/research/evolution-seeds/`.</Sidenote>

Six numbers, and what they learned is a subtraction. That subtraction is not in any line of code. It is simply the one combination of six numbers, out of all of them, that lived longest.
That subtraction is not in any line of code: it is simply the one combination of six numbers, out of all of them, that lived longest.

## Evolution doesn't guarantee success

I ran 12 different random seeds for 60 generations each: 10 graduated within 22 generations, one took until generation 43, and in one the whole population got stuck in the same bad habit and never cleared more than 3 pipes in all 60 generations. With 40 seeds, 5 never learned.
I ran 12 different random seeds for 60 generations each: 11 learned, 10 of them graduating (a hundred pipes in a row) within 22 generations; in the last one the whole population got stuck in the same bad habit and never cleared more than 3 pipes in all 60. Over 40 seeds, 35 learned, and the median graduation came in generation 15.

This is the usual weakness of evolutionary methods. With no gradient to say which way is better, evolution can only stumble on better individuals by luck. If the whole flock looks alike and all of it is bad, the 20% of fresh blood takes a long time to turn things round. If the score refuses to climb, press Restart and begin with a new set of ancestors.

Expand Down
Loading
Loading