martyw.dev

Notes

Short-form — showerthoughts, links, observations, things that don't need a whole post.

Spent the week deciding on a physics engine for the game engine and landed on Rapier. It’s written in Rust and ships to the browser as WASM, which sounds like overkill for a 2D hobby engine until you watch the alternatives fall over.

PhysicsJS was the first I crossed off — last meaningful release was years ago, and I’m not building on something nobody’s touched since the previous decade. Matter.js was the real contender: pure JS, easy to drop in, lovely docs, and a big enough community that every problem I’d hit has already been answered. But the maintenance has gone quiet, and once I started pushing body counts the frame budget got tight in a way I couldn’t profile my way out of.

Rapier wins on the two things I actually care about. It’s fast — the WASM core doesn’t blink at the body counts that made Matter.js sweat — and it’s deterministic across platforms, which matters the moment you think about networking or replays. The cost is a heavier, less JS-native API and a WASM blob to load. Worth it. I’d rather pay that upfront than fight the physics layer every time the simulation needs to grow.

The comments on here are down right now. They run on Cusdis — open-source, on their free hosted tier, and the most lightweight option I could find. I’ve used a few commenting systems over the years — FastComments, Disqus, that lot — and Cusdis was the leanest by a distance: no tracker payload, no account wall, just a textbox. The catch with lightweight-and-free is that when the hosted instance falls over, my comments fall over with it, and there’s nothing to do but wait.

Which is fine. Comments were never the point of this place, and only a handful of posts ever get any. But it’s a tidy illustration of what you sign up for when you lean on someone else’s free service: you’ve outsourced a feature and its uptime. I could write my own in an afternoon — it’s a form, a table, and a bit of moderation — but then the uptime would be mine too, and that’s the part nobody misses until it’s theirs.

New site, new shape. The old one was a developer-CV-with-blog. This one is broader — software, hardware, AI, the homestead, gaming, the occasional showerthought. Short stuff lives here in Notes; longer pieces under Posts.

Built with Astro and Tailwind on Cloudflare Workers. Most of the wiring — components, porting old articles, even the voice this note is written in — went through Claude Code. Worth a post of its own eventually.

The instinct when something’s slow is to put Redis in front of it. Sometimes that’s right. Often it’s a way to defer thinking about the actual problem, with the added bonus of now having two: the original slowness plus a cache that drifts out of sync with the truth in ways you can’t predict.

The question that filters most “let’s add a cache” proposals: how does it get invalidated? If the answer is “TTL and we’ll cross our fingers”, you’re not designing a cache, you’re rate-limiting how often your bug appears. A cache without a clear purging story — what triggers it, what gets purged, what happens when purging fails — is a wager that staleness will hurt less than slowness. Sometimes true. Rarely measured.

A common shape: fetch a flat list of rows, then group them in application code into a Map<parentId, Row[]> before returning. It works, it’s familiar, and it’s nearly always slower and more memory-hungry than asking the database to do it.

SELECT
  parent_id,
  ARRAY_AGG(id)::uuid[] AS child_ids
FROM children
WHERE parent_id = ANY($1)
GROUP BY parent_id;

One row per parent, the database handles the grouping, and the type cast keeps the application honest about what it’s getting back. MySQL has GROUP_CONCAT with the same idea. The harder ones to spot are the in-memory groupings already sitting in your codebase, doing it the slow way because that’s how it was written the first time.

The dataloader package ships with caching on. For the GraphQL resolver tree it was designed for that makes sense — stable reads within a single request are what you want, and the loader is garbage-collected with the request. Reuse a loader across requests or hold one for the lifetime of a server though and the cache becomes a Map that grows forever, never evicts, and quietly hands out stale reads.

I default to cache: false everywhere and only turn it on when the underlying data is genuinely small, immutable, and unambiguously worth the trouble — a lookup table of statuses, a tiny set of feature flags. Anything that can change while the process is running, the cache is off.