Phase 1: Challenge & Invite Flow#
Status: Implemented (branch: phase-1-challenge-invite-flow)
What Was Implemented#
1a. Post-Based Challenges -- DONE#
"Post to Bluesky" button on the game waiting screen. Creates a app.bsky.feed.post in the challenger's repo with:
- Text mentioning the opponent: "I'm challenging @{handle} to a game of chess on checkmate.blue!"
- Mention facet with correct UTF-8 byte offsets
- Link facet for the game URL
app.bsky.embed.externalwith title and description for the link card
Only shows when a specific opponent is set (not for open games). Button disables after posting to prevent double-posts.
Files created:
src/lib/bluesky.ts--buildFacets(),postToBluesky(),composeChallengePost()tests/lib/bluesky.test.ts-- 9 tests covering facet construction, byte offsets with unicode, handle edge cases
Files modified:
src/routes/game/[did]/[rkey]/+page.svelte-- added post button in waiting state
1b. DM-Based Challenges -- DONE (simplified)#
Originally planned as a chat.bsky.convo API integration. Investigation revealed that transition:generic scope does NOT cover chat.bsky.* operations -- adding DM support would require the transition:chat.bsky scope, broadening the permission grant.
Implemented instead: "Send via DM" button that copies the game link to clipboard and opens https://bsky.app/messages in a new tab. The user pastes the link in the conversation. Not as seamless as API-driven DMs, but works without additional OAuth scope.
Files modified:
src/routes/game/[did]/[rkey]/+page.svelte-- added DM button in waiting state
1c. Color Selection -- DONE#
White/Black/Random segmented control on the /play form. Game page logic fully decoupled from record ownership:
- Owner creates canonical record regardless of their color
white/blackfields determine color, independent of who owns the record- Non-owner creates child record with
parentGameUriregardless of their color - Jetstream, record pairing, and join logic all work for either color assignment
createGamenow accepts optionalwhiteparam (was required)
Files modified:
src/routes/play/+page.svelte-- color selector UI,resolveColors()helpersrc/routes/game/[did]/[rkey]/+page.svelte--loadGame()rewritten for color-agnostic logic,joinAsBlackrenamed tojoinGame,waitForOpponenthandles either colorsrc/lib/atproto.ts--whiteparam made optional increateGamesrc/lib/types.ts--challengerColoradded toChallengeRecordlexicons/blue.checkmate.challenge.json--challengerColorfield added
Also: Check-Before-Join Guard#
Before creating a child record to join a game, the game page re-fetches the owner's record to verify the slot is still empty. If it's been filled (race condition), the viewer falls into spectator mode instead of creating an orphaned record.
What Was NOT Implemented#
- Editable post text -- the Bluesky post uses a fixed template. An editable textarea (planned for Phase 5's game result sharing) was deferred.
- Bluesky post text adjusting for color choice -- the post always says "I'm challenging @handle to a game of chess." It doesn't mention which color the challenger picked. Could be added later.
- API-driven DM sending -- requires
transition:chat.bskyscope. Deferred unless there's a strong reason to broaden permissions.
Decisions Made During Implementation#
- OAuth scope is already minimal.
atproto transition:genericis the tightest scope available today. There are no collection-level scopes yet. Thetransition:prefix signals these are temporary and will be replaced by granular permissions eventually. - DM deep linking doesn't work. Bluesky message URLs use conversation IDs (
bsky.app/messages/{convoId}), not DIDs. Resolving the conversation ID requireschat.bskyscope. The fallback (open inbox) is acceptable. - Record ownership and color are now fully independent. The game URL always uses the creator's DID/rkey. The
white/blackfields determine who plays what. This is a meaningful architectural change from the original design where owner=White was assumed.