// Workstation4 Dev Blog

The
devlog.

Notes from the workshop. Build logs, post-mortems, weird Swift things, and what we're learning while shipping cool, strange, useful apps. Updated frequently. Often.

★ Latest · May 26, 2026 · 8 min read · kanji

Accepting any correct reading.

A kanji can have several valid readings, and a Japanese keyboard sometimes hands back the character itself instead of the kana you asked for. Here is how I made reading practice in my kanji trainer judge the answer instead of the typing.

Read post →
// All posts · by year

The full archive.

2026 → 2025
2026 19 posts
// 49 May 19, 2026

Climbing the kanji dependency graph.

Most kanji are built from simpler parts, so order matters. Here is how I merged a few open datasets into one ordered set, gated each character behind the ones before it, and let a small static page do the rest. kanji data srs
// 48 May 11, 2026

When a correct answer was impossible to submit.

Two word-reorder questions shipped with a tile that had no home in the sentence, so the Submit button could never enable. Here is how I tracked it down, why I fixed it in content rather than code, and the test that now makes it impossible to ship an unanswerable question. Bug Fix Content Testing
// 47 May 2, 2026

Gating example words by the kanji you already know.

Notes on Kanji Climb, a small kanji trainer whose build script picks example words for each character from only the kanji you have already met, so a sample sentence never quietly asks you to read something you have not learned. curriculum data python
// 46 Apr 26, 2026

Replacing placeholder rules with the real game's economy.

I swapped out the flat tax and generic payday that bootstrapped my Game of Life rebuild and wired in the decoded original economy: a per-career tax bracket table, routed cash tiles, salary curves, and real retirement payouts driven by 148 data-defined tiles. game-design rules simulation reverse-engineering
// 45 Apr 19, 2026

Bridges that move in front of or behind your car.

The original board has overpasses, so a car should sometimes drive over a bridge and sometimes tuck under it. Getting that to read as real depth came down to one generic rule, a list of tile numbers per sprite, and a small trick to stop it flickering mid-drive. godot gdscript graphics z-ordering
// 44 Apr 11, 2026

Driving cars along a branching board at one steady speed.

A forking board graph, a Catmull-Rom curve, and an arc-length table: notes on how I got the cars in Life to glide at one constant speed no matter how tight the corner, and why the obvious approach quietly cheats you. godot gdscript animation graphics
// 43 Apr 2, 2026

Why the game runs headless before it ever draws a pixel.

I built the rules of Life as a pure simulation that knows nothing about the screen. A happy side effect: the whole thing can play full games from the command line with no window open at all. architecture godot game-design testing
// 42 Mar 27, 2026

Decoding a 1998 game's BOLT archive, byte by byte.

A 1998 CD-ROM kept its art behind a custom container called BOLT. A misleading field name sent every offset into garbage, and untangling that, plus a green pixel and an LZ decompressor I had to read off the disassembly, turned out to be the fun part. reverse-engineering file-formats python
// 41 Mar 20, 2026

Reading the shape of a recovered catalog with offline audio analysis.

Eighty-six tracks arrived as bare MP3s with no tempo, key, or genre. Here is the offline Python pipeline I built to read the shape of the catalog, how I reconciled three different opinions about each number, and why I still wanted a person to decide what is true. audio python archive dsp
// 40 Mar 12, 2026

Two analytics writes became one atomic write, and the orphans went away.

Roughly half of my iOS sessions were arriving with a result but no per-question answers. The cause was a gap between two writes that iOS kept cancelling. Here is how I closed it with one Postgres transaction, and the JSONB gotcha that surprised me along the way. Swift Analytics Supabase Postgres
// 39 Mar 3, 2026

Grading typed answers without being a pedant.

A trailing period should not fail a correct answer, and a space-joined sentence should not fail in Japanese. Here is how a word-reorder bug pushed me toward a forgiving, deterministic matcher shared across 46 languages. Swift Kotlin NaturalLanguage Scoring Unicode
// 38 Feb 25, 2026

Tiles: study, recall, feedback, and a scheduler that picks the board.

Japanese Tiles runs each round through three phases, study, recall, feedback, with an SM-2 variant underneath that tracks stability and difficulty per word and quietly decides which words land on the board and how many. typescript react srs
// 37 Feb 18, 2026

A smooth JQuake client, built as a small Tauri proof of concept.

JQuake's real app is Java, and our role is mostly hosting and support. On the side I built a little client of my own as a proof of concept: a Rust and TypeScript Tauri experiment that decodes about 1,744 live seismic stations every second and paints them onto a MapLibre map without dropping frames. Tauri Rust Maps
// 36 Feb 10, 2026

Public profiles, universal links, and a follow graph that stays bounded.

Turning private game records into opt-in public profiles, rendering them server-side from synced scores, wiring a single link back into the iOS app, and capping a follow graph before it can sprawl. web social iOS API
// 35 Feb 1, 2026

Pruning the op log without breaking devices that fell behind.

An append-only sync log grows forever. Here is how I prune it on a six-hour schedule, and how a single per-user watermark keeps a phone that has not synced in months from quietly losing data. sync backend data
// 34 Jan 26, 2026

An append-only operation log for syncing game records.

Whole-record last-write-wins can quietly lose edits across devices. Here is how I ended up syncing Pinball Points game records as an append-only log of operations instead, and the small details (sequence cursors, idempotency, compaction) that made it hold together. sync backend TypeScript PocketBase
// 33 Jan 19, 2026

Keeping the model key off the device behind a small Express proxy.

Pinball Points reads scores from a photo with a vision model, which means a provider key has to authorize the call somewhere. The one place I didn't want it was inside the app on people's phones. Here is the thin proxy I built to hold it server-side instead. backend security Node.js OpenAI
// 32 Jan 11, 2026

Reading pinball scores from photos with multi-frame consensus.

Pinball score displays turned out to be a tricky read, so I stopped trusting any single photo. I capture a short burst, let cheap reads vote on the answer, and only spend on a stronger model when the votes disagree. AI vision iOS
// 31 Jan 2, 2026

Adding word reorder and minimal pairs to the test.

The v2 schema pushed the placement test past multiple choice into sentence-building and sound discrimination. Each new item type ended up needing its own SwiftUI view, its own path through one shared scoring engine, and a safety net that runs every shipped question through the real matcher. Here is what I learned. SwiftUI Question Types Scoring Content
2025 30 posts
// 30 Dec 27, 2025

Fixing question data without an App Store release.

A wrong answer key used to mean a full App Store submission. Splitting code and content into two ship channels, glued together by a per-language checksum, turned that into a minutes-long server push. Here is how the manifest, the loader, and the offline fallback actually fit together. Swift Content Sync Architecture
// 29 Dec 20, 2025

Owning the full notification lifecycle, and a force-unwrap on the resume banner.

Two small fixes I was glad to get to: clearing stale notifications with prefix-scoped identifiers, suppressing banners while the app is open, and replacing a force-unwrap on the resume banner with a single safe binding. Swift Bug Fix Notifications SwiftUI
// 28 Dec 12, 2025

Tap to play: raycasting against a 3D cajon in the browser.

A tap on a 3D cajon should only sound when it actually lands on the wood, not on the empty space behind it. Here is how I used a Three.js raycaster, a tiny debounce, and OrbitControls sharing the same canvas to make missing feel as honest as hitting. three-js interaction raycasting web-audio
// 27 Dec 3, 2025

Synthesizing a cajon hit in the browser, one tap at a time.

No samples, no downloads. Every cajon strike in Subdrum is built live in the Web Audio API from three layered voices, a pitch-swept sine, a triangle, and a noise burst, with a little randomness so no two hits land exactly the same. web-audio synthesis three-js
// 26 Nov 27, 2025

Localizing the whole app into 38 languages.

Notes on localizing Sequence, app and widgets, across 38 store locales, with a String Catalog of 855 keys keeping things honest and a Fastlane screenshot pipeline that renders all eight hero screens unattended. Localization Swift Fastlane iOS
// 25 Nov 20, 2025

Generating routines with an on-device model.

Describe a routine in plain language and Sequence drafts a playable set of steps, entirely on device with Apple's FoundationModels on iOS 26, so nothing about it leaves the phone. Swift AI Privacy
// 24 Nov 12, 2025

Going from four card types to eight.

Doubling Sequence's card types from four to eight meant a careful pass through the whole stack, plus one interval expansion bug that gently showed me where the authored model and the runtime model had quietly drifted apart. Swift SwiftData Feature Data Model
// 23 Nov 3, 2025

Rebuilding Sequence around play-first instead of edit-first.

The early build of Sequence dropped you into an editor, but I almost never open a timer app to edit. Here is how I came around to rebuilding it around the play button: a typed navigation route, a player that holds still, and a reorder bug that turned out to be about identity. SwiftUI SwiftData UX iOS
// 22 Oct 28, 2025

Importing 49,712 public trivia questions into Quizo.

Hand authored quiz packs are finite. To give Quizo close to endless rounds, I parsed the OpenTriviaQA bank, a plain text format that is friendly for humans and just loose enough to need a careful, defensive reader. data parsing typescript content
// 21 Oct 21, 2025

Rewarding speed: how Quizo scores chat answers by who got there first.

A correct answer is not quite enough in Quizo. I record every answer with a timestamp, rank the correct ones fastest-first, and hand out 3/2/1 points to the front of the pack. Here is how that one rule reaches into the parser, the database, and the tests. scoring twitch sqlite game-design
// 20 Oct 13, 2025

Making a quiz overlay behave inside OBS.

An OBS browser source looks like a web page, but it runs under rules a normal tab never has to obey. Here is what I learned getting Quizo's overlay to render transparently, stay smooth in OBS's embedded Chromium, and keep its countdown honest. obs overlay react performance
// 19 Oct 4, 2025

One state object, many screens: how Quizo keeps live trivia in sync over WebSockets.

Why I made the Quizo overlay a thin renderer, let one server-owned state machine drive every screen over WebSockets, and what the OBS browser source quietly taught me about timers. websockets state-machine architecture obs
// 18 Sep 28, 2025

A one-line fix for iOS 26's floating tab bar on iPad.

iOS 26 quietly switched iPad to a floating top tab bar, and it nudged my toolbar's plus button into the clock and battery. My layout code never changed; the platform default did. The fix was one modifier, but understanding it taught me more than the line itself. SwiftUI iPadOS Bugfix
// 17 Sep 21, 2025

Letting people trade speed for confidence with one honest toggle.

A pairwise ranking quiz buys confidence with taps. A single Quick versus Thorough toggle lets each person pick their own budget, with the trade shown up front and the math behind it kept honest. Swift SwiftUI UX Algorithms
// 16 Sep 13, 2025

The Pokemon generations bug: when I asked for more items than exist.

Generating a list of Pokemon generations kept failing, and the culprit was not a code path. It was my own prompt, quietly asking for more items than the world contains, and two acceptance floors that scaled the wrong way. Swift Apple Intelligence FoundationModels Bugfix
// 15 Sep 4, 2025

A three-tier generator so any prompt produces a usable list.

Turning a single line of free text into a clean, rankable list needed to work offline and keep what you type on the phone. Generation falls through three tiers, then a validator and a quality pass decide whether the result is good enough to show. Here is how it actually works. Swift Apple Intelligence On-device AI
// 14 Aug 29, 2025

Why I rank with Bradley-Terry instead of a running tally.

Counting wins throws away who you beat, and it never tells you whether your #1 is real. So I fit a Bradley-Terry model with gradient ascent, bootstrapped the confidence, and learned a lot about keeping all of it fast and honest on a phone. Swift Statistics Algorithms
// 13 Aug 22, 2025

Why shared drives are copies, not CloudKit shares.

Personal sync came almost for free with SwiftData and CloudKit. Sharing a drive turned out to be a different question, and the answer I kept landing on was an independent copy passed through a tiny upload service, not a live share. SwiftData CloudKit Architecture Node.js
// 12 Aug 14, 2025

A drive timer that froze at every red light.

Ghost Car's elapsed-time field was already reading the wall clock, so I assumed it was fine. Then it stalled at a red light. The bug was not in the math; it was in what made the math run. Here is the small fix and what it taught me about sensors versus clocks. Swift CoreLocation Observation Bug Fix
// 11 Aug 5, 2025

Moving heavy drive math off the main thread before Swift 6.

A wall of isolation warnings stood between Ghost Car and Swift 6. The fix was mostly one keyword, nonisolated, plus a couple of careful exceptions where I had to promise the compiler something it could not check on its own. Swift Concurrency SwiftData
// 10 Jul 30, 2025

Trimming idle time when a drive stores no per-point speed.

A recorded drive in Ghost Car is just a list of lat/lon points, no speed attached. To trim the dead time at each end, I reconstruct speed from coordinate deltas and move indices instead of editing data, so the comparison stays honest and easy to undo. Swift Algorithms CoreLocation
// 09 Jul 23, 2025

Aligning two drives so the comparison is actually fair.

Two recordings of the same commute never line up by clock time. Here is how I built a comparison engine with six alignment modes, a heading-aware split detector, and a deliberately dumb data shape, to make the match honest. Swift Algorithms CoreLocation SwiftData
// 08 Jul 15, 2025

Trading a custom URL scheme for Universal Links.

A custom URL scheme failed silently for anyone who did not already have WordErase installed. Switching to Universal Links gave me one https address that opens the right puzzle in the app, or a real web page for everyone else. iOS Universal Links Web
// 07 Jul 6, 2025

Logging a year by voice: App Intents over a shared SwiftData store.

I wanted to log a habit without opening the app. Here is how an App Intent opens the same SwiftData store the app uses, lets Siri name your trends, and never writes two entries for the same day. App Intents SwiftData Siri CloudKit
// 06 Jun 30, 2025

A banner ad that quietly froze the launch for 30 seconds.

After I fixed the launch crash, the puzzle still took about 30 seconds to appear. The cause was an AdMob banner booting an entire WebKit stack before SwiftUI would draw a single tile. The fix was about ordering, not speed. Swift SwiftUI Performance AdMob
// 05 Jun 23, 2025

Why turning on CloudKit crashed WordErase on launch.

Flipping on iCloud sync crashed WordErase the instant it cold-started. The cause was a quiet rule SwiftData inherits from CloudKit's record model, and the real fix came in two parts plus one lesson about provisioning warnings I learned to ignore. Swift SwiftData CloudKit
// 04 Jun 15, 2025

Cutting a 40-second puzzle load down to nearly instant.

First launch took over 40 seconds before a single tile appeared. The fix was not faster downloads; it was loading the right thing first and letting everything else fill in quietly behind it. Swift Performance Networking Combine
// 03 Jun 6, 2025

Surviving the tunnel: treating GPS silence as data.

Filtering bad GPS fixes is one problem. Getting no fixes at all is another. Here is the little watchdog timer I used to keep the number honest when you drive into a tunnel, and the two thresholds that stopped it from flickering. Swift CoreLocation CoreMotion GPS
// 02 May 31, 2025

Keeping the speed honest when GPS goes blind.

GPS quietly fails indoors and in parking garages, so a speedometer can freeze at 0.0 while you are clearly walking. Here is the two-sensor fallback I built, the thresholds I had to tune, and how I kept the number from telling a comfortable lie. Swift CoreMotion CoreLocation Sensors
// 01 May 24, 2025

Five small filters that calmed the startup speed spike.

A fresh GPS fix often hands you a stale reading, and a speedometer that trusts it can flash a wild number on launch. Here are five small guards I layered into the location callback to keep that first number honest, plus the sensor fallback that catches what GPS misses. Swift CoreLocation CoreMotion GPS