Making Friends (The Most Ambitious Day)

Eighteen tasks, 35 commits, one feature: the social identity system went from brainstorm to working Apple + Google auth, friend graph, profiles, avatars, and invites in a single session. Then the QA pass found 15 bugs.

Eyal Harush13 min read

There's a moment in every ambitious feature where you look at the plan, count the tasks, and realize you're either going to ship all of it today or none of it. Half a social system is worse than no social system. You can't ship "we have friend requests but no way to accept them" or "profiles work but auth doesn't." It's all or nothing.

Day 20 was all.

The scope

GEO-169 landed on the Linear board as a single issue: "Social Identity." The brainstorm that followed scoped it out into something that, in retrospect, was probably too big for one session. Apple Sign-In. Google Sign-In. A friend graph with request/accept mechanics. Player profiles with tabs for stats, run history, and friends. Twelve AI-generated avatars. Share links and friend codes for invites. A web invite page. Deep link handling. Display name resolution. Leaderboard integration for friends-only filtering. Privacy manifest updates.

Eighteen tasks. I dispatched them to subagents (fresh agent per task, spec compliance review after each) and started watching commits land.

Some of the design decisions were quick. No Facebook sign-in ("no facebook. next"), the SDK overhead and the chicken-and-egg problem of needing friends to make a friend graph useful made it a non-starter at current scale. Request/accept friend model, not a follow model. Game Center friends auto-imported into the custom friend graph. No dedicated sign-in screen; link buttons live on the profile panel, Game Center remains the default entry point. Share links and friend codes for invites, no contacts access. And one I particularly liked: deferred deep links via device fingerprinting. If you tap an invite link on the web before the app is installed, the web page drops a cookie with a device fingerprint (IP + user-agent hash, 24-hour TTL). When you install the app and open it, the server matches the fingerprint and auto-completes the friend request. "cookie magic, i like, let's do."

The server tasks went first. Schema migration. Identity module extensions for Apple and Google token verification. A new social module for the friend graph. Integration tests. Tasks 1 through 8 landed cleanly because server modules have clear boundaries: each module owns its tables, exports a typed interface, and doesn't touch another module's data. The subagent model works well when the contract is crisp.

The iOS tasks were messier. Auth managers for Apple and Google. A profile panel with three tabs. Avatar selection. Friend list with request/accept/decline/remove actions. The UI work required more back-and-forth than the subagent model expects. UI feedback is inherently interactive. You can't batch-process "this button should be 4px further left" into a single-pass spec. I found myself stepping in more on tasks 9 through 14, adjusting spacing, fixing navigation, rewriting the tab bar. One reminder I had to give three times was about a delete button's styling ("this is the 3rd time im asking about this"), which drove home that UI polish feedback needs to stick the first time, or you end up repeating yourself more than debugging.

Then the web tasks: an invite page that resolves a friend code, shows the inviter's nickname and avatar, and deep-links into the app. These ran in parallel with the iOS work.

By the end of the session: 35 commits on the feature branch. Working auth. Working friend graph. Working profiles. Working avatars. Working invites. The feature was done.

Except it wasn't.

The profile panel: stats tab with avatar, nickname, and global rankings

The debugging marathon

Three bugs consumed the rest of the night, and all three were invisible.

Bug 1: Content-Type on empty bodies. Every social action that didn't send a payload (share a friend code, accept a request, decline, remove) silently failed. No error in the iOS console. No crash. The friend request just... didn't send. The accept button did nothing. The remove action did nothing.

I spent way too long on this. The iOS APIClient was setting Content-Type: application/json on every request, including POST and DELETE requests with no body. Fastify, reasonably, rejects Content-Type: application/json with an empty body. It throws FST_ERR_CTP_EMPTY_JSON_BODY. But the error was invisible because the client-side error handler wasn't reading the response body, and the server-side error handler wasn't logging at warn level. The request went out, the server rejected it, and neither side said anything useful about why.

"this took way too long to debug"

The fix was two lines. Only set Content-Type when the request has a body. Two lines. The diagnosis took hours because there was nothing to see. No error message, no stack trace, no crash. Just silence.

That night I added a rule to CLAUDE.md: always read the server's error logs. And a second rule: don't set Content-Type: application/json on empty-body requests. Both rules exist because I burned hours learning them the hard way.

"no, do not simplify, that would be ignoring the issue, it might be correct, but do it only after you realize exactly what's wrong. read the server's logs, add more if needed."

Bug 2: IPv6 localhost. The iOS simulator intermittently couldn't reach the local dev server. Connection refused errors that looked like the server had crashed. It hadn't. The problem: localhost on macOS sometimes resolves to ::1 (IPv6), but the Fastify dev server only binds to 0.0.0.0 (IPv4). Fix: use 127.0.0.1 explicitly in simulator builds. Another two-line fix. Another 30 minutes of staring at logs that said "connection refused" without explaining why the connection was being refused.

Bug 3: friendship status strings. After sending a friend request, the UI was supposed to show "Pending" instead of "Add Friend." It didn't. The button stayed on "Add Friend" forever. The server was returning pending_sent and pending_received. The iOS ProfilePanel was checking for request_sent and request_received. Pure string mismatch. No compiler warning because Swift was comparing String to String. Both valid, both wrong.

Three bugs. Three two-line fixes. Probably four hours of combined debugging time. This is the pattern I keep hitting: the fix is trivial; finding it is the hard part. When errors are swallowed, when logs don't exist, when the mismatch is between two valid strings that happen to not be the same string, you're debugging in the dark.

The Google OAuth detour

Mid-session I had to set up Google OAuth client IDs, and this became its own minor saga. The gcloud CLI doesn't support creating OAuth client IDs; it's a Console-only operation. I created three clients manually: an iOS client, a dev web client, and a production web client.

The non-obvious part: the server verifies Google tokens using the iOS client ID, not the web client ID. That's because the ID token's aud (audience) claim matches whichever client issued the token. The iOS app uses the iOS client ID, so the token it sends to the server has the iOS client ID as aud. The server needs to know that client ID to verify the token. I wired this wrong twice before it clicked.

The production credentials went into GCP Secret Manager. The dev credentials went into .env. Nothing got committed to the repo.

The avatar pipeline

Same day, same session, I built a small pipeline that generates 12 character portraits for profile avatars. It reuses the zone art pipeline's OpenAI API utilities: one prompt template, one style reference (inge.png, the player character's sprite), twelve character descriptions in a characters.json file.

The first batch had a problem: Inge's mining helmet was bleeding into every portrait. The model was copying the reference character's outfit onto every new character. Fixed by adding "Do NOT copy the reference character's outfit" to the prompt. Then I gave each character a unique exploration item (headlamp, pickaxe, compass, lantern, climbing rope) to fit the cave exploration theme.

"they are perfect, let's use them in our avatars"

Twelve diverse portraits, integrated into Resources/Avatars/, selectable from the profile panel. Start to finish: maybe 90 minutes, including the helmet-bleed debugging.

Three days later: the QA reckoning

Day 20's implementation session ended around midnight. I felt good about it. 35 commits, everything working, biggest feature in the project's history. Then I came back to it on day 23 with fresh eyes and a 100-item QA checklist.

Fresh eyes are brutal.

I opened the checklist in VS Code preview mode, ran two simulators side by side, and started clicking through every flow. Profile creation. Nickname editing. Avatar selection. Friend request send. Friend request receive. Accept. Decline. Remove. Leaderboard filtering. Coin rewards for linking a provider. Share link generation. Web invite page. Apple Sign-In. Google Sign-In.

Fifteen bugs. In a feature I had called "done" three days earlier.

Some were small: the friend row wasn't fully tappable (only the label and chevron were links, not the whole row). Badge colors were inconsistent between the profile tab and the friends list. The nickname edit had no cancel button; once you started editing, your only option was to submit.

Some were medium: coins weren't updating after linking an auth provider because the progressionStore never got credited. The server was granting the reward, the response confirmed it, but the client never refreshed its local balance. Submitting an empty nickname said "nickname contains inappropriate language" instead of a proper validation message. The profanity check ran before the length check, and the profanity library flagged an empty string as suspicious. The display name fallback showed "Player" instead of the device name because the server wasn't including the displayName field in the profile response. It was stored correctly, just not serialized. I built a single display name resolution helper: nickname first, then displayName, then "Player" as the last resort. Used it everywhere.

"profanity is still easily passable - 'xxxfuck' works"

That was the profanity filter. The original library, bad-words, only matched whole words. "fuck" was banned but "xxxfuck" sailed through. I switched to leo-profanity with substring matching. Any banned word of 3+ characters found inside the input gets caught. "xxxfuck" is now blocked. "Pro" and "IcePeak" pass clean. The false positive risk is negligible for 2-20 character nicknames.

The CSP wall

And then there was the web invite page.

The invite page was supposed to be simple: hit the API with the friend code, get the inviter's nickname and avatar, render a "Join [name] on Geo Climber" card with a download button. In development it worked. In development everything always works.

When I tested it through the actual flow (generate a share link from the iOS app, open it in Safari), infinite loading spinner. Nothing rendered. No error on the page. The loading spinner just spun forever.

"still infinite loading invite. add debug logs if not enough, and let's debug"

First fix: the JSON unwrapping was wrong. The API returned {data: {nickname}} but the code was reading it as {nickname} directly. Fixed that. Still broken.

Second fix: created .env.local with the local API URL so the dev server would hit localhost:3000 instead of api.geoclimber.app. Still broken.

I added console.log statements everywhere and asked myself to check the browser console. That's when it appeared:

EvalError: Evaluating a string as JavaScript violates the following
Content Security Policy directive

The Content Security Policy in next.config.ts was blocking two things: unsafe-eval (which Next.js's hot-reload needs in development) and any connect-src that wasn't api.geoclimber.app. The page rendered fine server-side. It showed the loading spinner, which is static HTML. The client-side JavaScript that would fetch the invite data and replace the spinner with actual content was silently killed by CSP before it could execute.

This is a class of bug where the page looks like it's working. Server-side rendering produces valid HTML. The loading state renders. The CSS loads. The fonts load. Everything looks normal except the JavaScript is dead. No visible error on the page. No crash. No console error unless you explicitly open the dev tools and look for CSP violations in a sea of Next.js hot-reload noise.

The fix: make CSP environment-aware. Production keeps the locked-down policy. Dev mode adds unsafe-eval and localhost:3000 to connect-src. Three lines in the config. The invite page loaded instantly.

"works!"

Apple Sign-In: the one that got away

I tested Apple Sign-In on a physical device. The native Apple sheet appeared. I tapped "Sign in." The sheet dismissed. Nothing happened.

First fix: added presentationContextProvider to ASAuthorizationController. Still nothing.

Second fix: the controller was a local variable inside withCheckedThrowingContinuation. Swift's ARC was collecting it before the delegate callback fired. The ASAuthorizationController showed the sheet, the sheet completed, the controller was already deallocated, the callback had nowhere to go. Added an activeController property to hold a strong reference through the async flow. Still nothing.

Third attempt: added debug logs through the entire chain. The logs showed the full flow completing: token received, sent to server, server returned... 401. That's when it clicked. The physical device hits api.geoclimber.app. Production. The social routes weren't deployed to production yet.

"so why did google work?"

Google worked because I'd been testing it on the simulator, which hits localhost:3000. Not because Google's implementation was better, but because the simulator hits a different server. The bug wasn't Apple vs. Google. It was simulator vs. device.

"nvm, clean up. and we'll test in testflight."

I stripped all the debug logs and moved on. Apple Sign-In would get its real test when the social routes shipped to production.

The real ratio

Here's what the social identity feature taught me about shipping: the implementation is maybe 40% of the work.

Day 20's implementation session was exhilarating. Eighteen tasks, 35 commits, a plan executed at speed, subagents landing server modules like clockwork. If I'd stopped there and called it done, I'd have shipped a feature with 15 bugs, including one that made the entire web invite flow non-functional and another that made every friend action silently fail.

The other 60%, the debugging marathon on day 20 itself and the QA reckoning on day 23, is where the feature actually became shippable. Content-Type headers. String mismatches. CSP policies. Profanity filter edge cases. Controller lifecycle bugs. Display name resolution. None of this is exciting. None of it makes a good demo. All of it is the difference between "it works on my machine" and "it works."

What made the QA pass effective was that it was systematic. Not "play with the feature for 20 minutes and see if anything feels off." A 100-item checklist organized by flow: auth linking, profile editing, friend requests, friend management, leaderboard, invites, web page, edge cases. Two simulators, each logged into a different test account, sending friend requests to each other. I went through every checkbox, marked pass or fail, and triaged the failures into must-fix bugs, UI polish items, and deferred issues (things that needed TestFlight or production to test properly).

A few items landed in the deferred pile and stayed there: Apple Sign-In needs a physical device with production routes deployed. Unlinking the last auth provider needs two providers linked first. Linking an already-linked provider to a second account needs two separate accounts. These are real test cases, but they require infrastructure I didn't have at the time. They'd wait for TestFlight.

The 35 commits landed the feature. The 15 bug fixes shipped it.

I created a permanent Discord invite link and updated all 7 references across the iOS app, the server email template, the web footer, layout, support page, and updates page. Stripped the debug logs. Marked the QA checklist complete.

The social identity system (auth providers, friend graph, profiles with tabs, avatars, invites, web deep links, leaderboard integration) was ready for its PR.

This is post 14 of 18 in a series about building Geo Climber with Claude Code. The social system shipped. Eighteen tasks, 35 commits, 15 QA bugs. Implementation is the exciting part. Debugging is where it actually ships. Join the Discord and download Geo Climber on the App Store.

social-identityfriendsauthdebuggingqasubagentscontent-type-bugcspavatarsclaude-code
Making Friends (The Most Ambitious Day) — Building Geo Climber