Context
This is the third version of my portfolio. The previous one was Nuxt 2, Vue 2 and particles.js, with scroll-to-route navigation: scrolling past the end of a page took you to the next one. It looked fun in a demo and frustrated people in practice, and it was hard to keep fast.
For this version I wanted something minimal and dark, with one playful idea at its core: a small arcade of daily puzzles where every visitor gets the same puzzle, and the time to beat is mine. Around that, the usual portfolio pages, done properly: fast, accessible, readable on a phone, and honest about every number on them.
Goals and constraints
- Fast on a mid-range phone. Lighthouse mobile as the yardstick, with LCP under 2.5 seconds as the target.
- Fair games. The same seeded puzzle for everyone, solvable by logic alone, and a leaderboard that can't be faked by editing a request.
- Private by default. No cookies, no consent banner, no personal data stored.
- Accessible. Keyboard and screen reader support for every interaction, including the games, and full respect for reduced motion.
- Built and shipped solo, so every dependency had to earn its place.
Approach
A starfield in 7 KB instead of 241 KB
The first background used three.js through React Three Fiber. When I measured it, the lazy-loaded chunk was 241 KB gzipped against a budget of 150 KB. A drifting starfield needs one buffer of points, one small shader pair and one draw call, so almost all of three.js was dead weight.
I rewrote it in plain WebGL2: about 7 KB. It keeps the original look (a slowly rotating 3D field with mouse parallax and perspective-sized stars), and adds a few things that would have been fiddly through a scene graph: a warp with streaks on every page navigation, a rare shooting star, colour temperature per star, and a supernova when someone beats my time in a game. It loads when the browser is idle, picks a quality tier from the device, pauses when the tab is hidden, and is skipped entirely under reduced motion.
Shipping the hero illustration once, not twice
The hero is a traced illustration with roughly 500 paths. As a React component, its path data was sent twice: once as HTML and once again inside a 123 KB JavaScript chunk. I moved the art into a single cached SVG file and render it with <use> references. Home's HTML went from 170 KB to 48 KB gzipped, and the chunk disappeared.
That structure also made the illustration interactive cheaply. Hovering a nav item or a hero button animates its part of the drawing: the lamp switches on, the laptop screen glows, the beer bottle fills. These are CSS animations on top of the shared art, with a keyboard equivalent for every one.
Daily puzzles from a seed
Minesweeper, Quick Math and Mini Sudoku generate today's puzzle from a key like ms-v1-2026-10-10, using a seeded random generator, never Math.random or the clock. Changing a generator rule bumps the version, so old keys keep producing old puzzles. Minesweeper and Sudoku only keep a board if the solver can finish it without guessing.
A run in progress is saved as a start time plus a move log, never the board itself. Leaving and coming back resumes the run with the clock still running, and a saved run is validated before it's trusted.
Global stats you can't fake by editing a request
The puzzles are public and deterministic, so the server can't trust the browser. Starting a run issues a signed token with the server's start time. Finishing a run sends the move log, which the server replays with the same game engine to confirm it solves today's puzzle. The official time is measured on the server, penalties are recomputed from the log, and a player can only record one run per puzzle. Rate limiting and size limits sit in front of both routes.
It isn't cheat-proof: someone could solve the puzzle offline and replay it. But it makes faking a result tedious, which is the right level of effort for a portfolio.
Privacy and analytics
Analytics are cookieless. The visitor count on the Projects page comes from a small counter of my own: each visitor is a hash of a daily salt, the date, their IP and browser, and only the hash is stored, so nobody can be followed from one day to the next. Product analytics track a short, deliberate list of events, such as a game finished or a result shared, and never anything personal.
Mobile and accessibility as a first pass, not a final one
Everything is built mobile-first: a bottom tab bar on phones, 44 px tap targets, dvh units, and no horizontal scroll at 360 px. Every hover effect has a keyboard-focus equivalent. Games are playable by keyboard and announce their state to screen readers. A command palette (⌘K or /) reaches every page, game and action.
Results
- Starfield: about 7 KB, down from 241 KB.
- Home page: 48 KB of HTML instead of 170 KB, and the hero's 123 KB JavaScript chunk gone.
- Largest Contentful Paint on Home (Lighthouse, mobile): 3.6 s to 3.0 s from one change, no longer hiding page content until JavaScript had loaded, before the hero rework above.
- Three daily games, with server-verified global stats and no cookies anywhere on the site.
What I'd do differently
Write tests alongside the code, not at the end. I deliberately left tests for last to keep momentum. It cost me: a validation check with its condition inverted meant a saved Sudoku run was never resumed, a stale variable made Quick Math report every win as a win over me, and a caching default silently kept real stats off two pages. Each was a small fix, but each was found by hand, and each would have been a one-line test.
Measure before building. The two biggest wins, the starfield and the hero, both came from measuring something I'd assumed was fine. I'd now put a size budget in CI from the first day instead of discovering the problem later.