chang.kim: A Build Retrospective
The honest answer to “why does my personal site look like a JRPG” is that I sat down to build a normal portfolio, looked at the hero-image-and-CTA layout one more time, and realized I’d rather not. The metaphor of a small overworld with a few rooms in it — a homelab room, a gallery room, a resume room — is a better description of what a personal site actually is than the marketing-page convention is, and the playful framing makes it easier to admit. So that’s the version that got built.
The constraints did the design work
Every interesting decision in this site came out of a constraint, not an aesthetic preference.
The aesthetic — 16-color JRPG, ASCII characters on a top-down grid — came partly out of taste and partly because it forces every visual decision through a single small palette. There is no hero image. There is no font-pairing crisis. There is a <canvas> element and a CSS file shorter than most config files I’ve shipped.
The stack — Astro 6, vanilla canvas, Cloudflare Pages — came out of three constraints that ruled out the obvious alternatives:
-
Pure-static output: the host is Cloudflare Pages on the free tier, so anything that wanted a runtime server (
next/imagein export mode, hosted CMS, Pages Functions) was out. Astro 6 ships pure static by default and itsastro:assetsimage pipeline does AVIF/WebP at build time, which is what I needed. -
A canvas that isn’t a game engine in disguise: the room-graph world is a 2D grid of cells drawn as fixed-width characters. There is nothing here that earns the bundle weight of Phaser or Pixi — no physics, no audio scheduling, no sprite atlases — so the engine is a few hundred lines of vanilla canvas + TypeScript. The bundle is tiny by design, and I know every line of it, which matters when a personal site is also a thing you have to maintain alone.
-
A pipeline that runs on the CI I already had: I run a self-hosted GitLab on Kubernetes. Cloudflare Pages’ git integration doesn’t support self-hosted GitLab, so the deploy is Wrangler Direct Upload from the existing pipeline. This was less painful than it sounds; it took about as long as the original Namecheap SFTP setup did, and it produces atomic deploys without the content-hashed-filename gymnastics I’d planned for.
The whole deploy job, after everything settled, is essentially this:
npm run build
wrangler pages deploy dist --project-name=chang-kim
That’s it. Two commands. The CI does some EXIF scrubbing and lint and asset checks around it, but the deploy itself is two lines.
What I would do again
The Cloudflare Pages pivot, which I made mid-build after Namecheap’s shared host hit a storage cap during Phase 0.1 The free tier here is generous in exactly the ways shared hosting wasn’t: unlimited bandwidth, atomic deploys, automatic SSL, custom domains that just work. Switching cost me an evening. I’d switch again on day one if I were starting over.
The vanilla canvas engine. [REPLACE — your actual feelings here: was it satisfying to write, frustrating, or somewhere in between?] The tradeoff against using a framework was that I had to write my own minimap, my own announcer-line a11y, and my own room-graph traversal — but each of those was small enough that the maintenance cost is low, and the bundle is tiny enough that I can keep it that way.
What was harder than I expected
The accessibility design. The dual-surface contract — a visual canvas plus a hidden DOM tree plus an aria-live announcer — has to be designed in from day one, not retrofitted. [REPLACE — one or two anecdotes from the a11y work: which screen reader behavior surprised you, what test caught a regression you didn’t expect, the moment you realized the keyboard-only path needed its own thinking.]
The image pipeline. Sharp encoding AVIF inside a k8s runner pod with 2 GB of memory will OOM on a non-trivial photo library unless you cap resolution and use a lower compression effort. The fix was small once the failure mode was understood; the surprise was that the failure mode existed at all on what looked like a generously-provisioned runner.
EXIF GPS scrubbing. Sharp does not strip metadata by default. The build pipeline has an explicit CI exiftool check that fails the build if any photo output carries GPS — a discipline a personal photography site needs from the first publish, not the second.
The part that’s still being figured out
The hidden rooms and easter eggs. The visible rooms — fortress, gallery, code, blog, resume, about, now, homelab — are well-defined. The completionist threading that turns the egg discoveries into a small satisfying loop is a Phase 5 problem, and I haven’t fully decided what that loop should feel like yet.
If you want the longer version of the design contract, the planning artifacts live in the repo under .planning/; you can walk the entire decision log if that’s the kind of thing you like to read.
Footnotes
-
Reader, it was 50 MB. ↩