Inspiration

If you play games, you own games you haven't played. A Steam library full of sale purchases, a subscription catalogue you opened twice, two things a friend insisted on, and a low-grade guilt attached to the whole pile. You know roughly how big it is. You couldn't name what's in it.

The moment I actually built for is narrower than that. A game goes past in a video at eleven at night, you think "that one", and by morning it's gone.

The Shipaton supplied the part I can't write for myself. I entered the Influencer Award for Gaming, and the category description turned out to be a better requirements document than any I've written for my own side projects: save games at the moment of discovery, judged on how well people can organize, complete, rate and share them, and on whether managing a backlog feels enjoyable rather than like another chore.

Not because those two sentences are insightful. Because they're fixed. Side projects don't die of bad code, they die of being redefined every week, and a brief you didn't write and can't edit removes that failure mode. When I couldn't pick between two data models I stopped deliberating about taste and re-read two sentences.

What it does

Barklog tracks your gaming backlog on iOS, with a dog keeping score.

Four statuses: waiting, playing, completed, abandoned. Most trackers assume you'll finish everything. Barklog doesn't, so abandoned is one tap away and it counts as an answer rather than a failure.

Share a clip into it and you get the game. A YouTube link, a TikTok, a Vimeo upload, an IGN review: Barklog reads the page's title and author, works out which game the video is about, and hands you the candidates to pick from. That's the eleven-at-night case, and it's the feature I'd demo first.

The rest is what you'd expect. IGDB's whole catalogue to explore and search, covers and platforms and summaries and similar games, a rating on the ones you finish. It's free for ten unfinished games, and finishing one gives the spot back. Premium takes the cap off, which is the whole difference.

How we built it

A Turborepo monorepo, TypeScript throughout: an Expo SDK 57 app, a Hono API, Postgres, a worker that wakes up once a night, and Valkey next to the API. Four things it doesn't own: IGDB for game data, Clerk for identity, RevenueCat for purchases, OpenAI for turning a shared link into a guess.

Almost every interesting decision was about which of those four a user waits for. The answer is none of them, except the one that can't be mirrored.

A nightly worker copies IGDB's entire games table into Postgres. 374,000 rows, about 748 requests, a few minutes once a night, and the API only ever reads from that copy. Their rate limit isn't mine and their 2 a.m. outage isn't mine either. The part I'd steal for other projects is how the boundary is enforced: @repo/igdb is in the worker's dependencies and not the API's, so the design can't quietly regress into a live proxy under deadline pressure. Conventions rot. Manifests don't.

The sync worker has no error recovery path, which is the design rather than an omission. A failed run writes no watermark, so the next run re-fetches the same range, and every write is an upsert or a scoped replace, which makes replaying a page free. There's no repair path to write because there's no partial state to repair.

Search runs on Postgres trigram matching, on word_similarity rather than similarity. Plain similarity compares whole strings, so zeld scores near zero against The Legend of Zelda: Ocarina of Time for reasons that have nothing to do with relevance. Ranking is 60% relevance and 40% clamped popularity, and that second term is what stops mario returning an obscure ROM hack above Super Mario Odyssey.

The share pipeline is where all the work is recovery. The URL gets canonicalised first, with YouTube and TikTok rebuilt from their native ids, and its SHA-1 is the cache key for everything downstream. Then a ladder: an igdb.com link resolves straight to a row in the mirror, 332 vendored oEmbed endpoints answer for the sites that have them, and anything else gets fetched and parsed for og:title. The rungs fail differently on purpose. A 401, 403 or 404 from oEmbed is terminal, because falling through to scrape a removed video reports "Video unavailable" as a title and then goes hunting for a game by that name.

Then two passes against OpenAI's Responses API on gpt-5.4-mini. Pass one sees the title, author and site at reasoning effort none. Only if it gives up does pass two run with web search, at roughly eighty times the price. Each pass reports which tier of evidence it used, and the two don't share an enum, so pass one can't claim a web-search basis. The provenance is enforced by the decoder rather than by the wording of a prompt, and that's the only reason I trust the label enough to print it. The screen says "Matches for the title" or "Games this channel usually covers", whichever the model earned.

The free tier is one function. slotDelta asks whether a status change consumes a slot, gives one back, or does neither, and it lives in a package the API and the app both already depend on so the client's optimistic check can't drift from the server's enforcement. Enforcement is a single transaction that opens with a SELECT … FOR UPDATE on the user's own row, counts only when the delta is positive, and answers 402 carrying the count and the limit.

RevenueCat is the one third party that can't be mirrored, because a purchase is a fact about money that just changed. It arrives twice, as a signed webhook and as a REST refresh the app can ask for, and both end at the same upsert. Every write carries its own timestamp and the upsert refuses to apply behind a value already in the row, so the race between them is settled by Postgres instead of by a branch somebody has to remember to write.

One more thing shaped all of it. An agent wrote most of the code, so over about two weeks I wrote more English about Barklog than there is TypeScript in it. The first thing in the repository that isn't scaffolding is a design document.

Challenges we ran into

Accepting links from strangers is the genuinely dangerous part. A server that fetches whatever people send it can reach cloud metadata at 169.254.169.254, a Postgres on 10.x, an admin panel on loopback. The version everyone writes, check the address and then fetch the hostname, doesn't work, because the connector re-resolves at connect time and an attacker's DNS answers public for the check and private for the connection. The fix is pinning the address you verified into the request's own dispatcher, and every redirect hop re-enters the whole check. The oEmbed matcher had a smaller version of the same problem: a glob compiled with .* lets https://evil.com/x.youtube.com/watch satisfy YouTube's scheme, and excluding / isn't enough, because WHATWG URL treats a backslash as a path separator too.

The cap I designed first was wrong. Ten backlog entries total is a rule everybody understands, and it's consumed in the first session and never recovers, because a finished game keeps its slot forever. The paywall would have arrived on exactly the behaviour the app exists to encourage. Catching that in a document cost an afternoon of rewriting. A week later it would have cost a migration.

Then a pile of small things that ate real days. Pass two truncating under a 256-token budget, which isn't an error, just a response that won't parse, because reasoning items and tool calls land in the same output array. The SDK's default retries buying a second web search for an answer I'd already paid for. An Instagram reel at 745KB turning a perfectly readable page into a 502 until HTML started truncating instead of refusing. Sign-out calling Purchases.logOut() on a cold launch, when Clerk has resolved to signed-out and the SDK's user is still anonymous. And a concurrency test that failed once in forty-four runs and never reproduced across forty-three more, which I've written down rather than quietly dropped.

Then App Review. The code was ready on 1 September and the app went live on the 14th, and almost nothing in those two weeks was code. Four submissions, three rejections: more information about what the app does, fixed with a walkthrough video; a missing EULA and empty fields in App Store Connect; and a reviewer who couldn't find account deletion, fixed with a second video. A first in-app purchase is reviewed alongside an app version and never on its own, so the entire free tier had to exist before 1.0. A production Clerk instance inherits nothing from your development one, which is why Apple and Google sign-in simply weren't listed, and diffing /v1/environment between the two is what made that visible. The fourth review took four days with nothing left to fix, and I was at the edge of my seat for all of them.

The quiet one: review became the job. An agent that writes ten times faster doesn't leave you with a tenth of the work. You get ten times the diff at the same reading speed you had before, and my reading speed was the ceiling on the whole project.

Accomplishments that we're proud of

It shipped, it's on the App Store, and the whole thing is open source at github.com/chornonoh-vova/barklog with the design documents and their rejected alternatives included.

The parts I'd defend hardest are the boring ones. Failure is a no-op wherever it can be: a failed sync run, a redelivered webhook, a delete for a user who's already gone. The cache is fail-open, so losing Valkey makes the app slower rather than broken. The share screen tells you what kind of evidence produced its answer instead of asserting a confidence nothing in the system holds. The cap counts hoarding rather than saving, so finishing a game can never be the action that gets refused, and a lapsed subscriber sitting at twenty games keeps all twenty and wins a slot back with each one they finish.

I'm also glad the SSRF work is there. It's the part most hackathon apps skip, and there's a test for the backslash case.

What we learned

A wrong document costs more than wrong code. Wrong code fails a test or fails review. A wrong design document gets implemented faithfully, everywhere it applies, and looks finished when it's done.

A prompt is advice and a schema isn't. If a label has to be trustworthy enough to put in front of a user, enforce it where the response is decoded.

A cache key for a model call has to name everything that changes the answer: prompt version, model, tool configuration. Anything you leave out is a cache serving answers from code you already deleted. And caching that call is product work before it's cost work, because without it the same link shared twice comes back as two different games, and that doesn't read as probability to the person holding the phone. It reads as a bug.

Owning a mirror makes you the publisher of somebody else's moderation lag. The age-rating questionnaire asks what your app contains, and the honest answer about 374,000 rows arriving nightly turned into a filter on the way in plus a nightly sweep for anything IGDB re-tagged later.

And shipping is mostly not code. The place the agent helped most was App Store Connect, which is the furthest thing from a codebase, for someone who'd never done it before.

What's next for Barklog

Localization. The app is English-only today and a backlog tracker has no reason to be.

Home screen widgets. The one place the "what am I playing" question gets asked is the home screen, before you've opened anything.

One question I'd rather have answered by people than by me: ten unfinished games, finishing one gives the spot back. I'm honestly not sure ten is right rather than merely defensible. If it's too tight, I'll ship the change.

And one open problem I'd rather write down than pretend I solved. A reviewer couldn't find account deletion, and what I shipped was a video showing where it lives. A real user hunting for the same thing doesn't get a video, and the path is still behind the avatar under Security.

Built With

Share this project:

Updates

Submission history