geometric packing game (tetris-ish) on an icosahedron
rad-cohesion docs atproto.md
13 kB · 222 lines
Markdown

Accounts, published scenarios, and finished runs #

The app uses browser OAuth for explicit scenario and finished-run publication. Game checkpoints and local result history stay in this browser. Signing in does not upload local data.

User flows #

  • The account button in the shared app header opens sign-in using an atproto handle, DID, or provider. The official browser OAuth client handles authorization, session storage, token refresh, PKCE, and DPoP. Open the account button menu to sign out.
  • Scenarios keeps local board setups and account-owned published scenarios separate. Publishing creates a public copy; it does not move or synchronize the local entry. Publishing again creates another record.
  • A public scenario link uses /scenarios/published/?uri=…&cid=… and includes its AT URI and CID. Anyone can open it while signed out, import a local copy, or start Free Play or a timed challenge at a chosen level. A missing or changed pinned version produces an error.
  • Library → Finished runs has Local and Published lists, with one list shown at a time. Submit score publishes the existing local run record to the signed-in account's PDS, or submits it to the Guest leaderboard while signed out. The optional public name is available only while signed out. Signing in does not publish local results automatically. The Published list supports refresh and deletion of the current account's records. There is no public run detail page in this slice.

Game over also offers Submit score beside Restart once the result is saved locally. Signed-in players publish with one click. Signed-out players see a guest submission dialog with an optional public name and a sign-in link. Failed submissions can be retried without leaving the result screen.

The built-in Default game starts from an empty board. Use New game to choose its starting level. It needs no account or published scenario record.

Local data belongs to a browser profile and site origin, not to the signed-in account. Switching accounts does not move, hide, or publish local data. A PDS record is public repository data; removing it cannot recall copies others made.

Data boundaries #

Structure Purpose
BoardScenario Reusable initial cells and colors, independent of a particular attempt.
ChallengeDefinition Scenario plus starting level, rules, RNG version, and timing policy; identifies a score category.
GameSessionSnapshot Complete live checkpoint used to resume an attempt.
PublicScenarioRecord Public name, creation time, and scenario content in a Lexicon-compatible representation.
FinishedRun Attempt setup and result totals, with no move history.
FinishedRunRecord Public summary and setup for one finished attempt, with no move history.

Core gameplay stays independent of OAuth and protocol records. Record mappers translate core cell-color tuples into objects supported by Lexicon. The app validates network and browser-storage data before use. A scenario reference is a DID-based AT URI plus a record CID; handles are display and discovery names.

A saved game launched from a public scenario keeps the pinned reference beside its checkpoint. The full challenge remains embedded, so resuming does not require downloading the public scenario again. An imported local scenario is an independent editable copy.

A fresh run records its start and finish wall-clock times, total playing time, and segment duration; total and segment duration are equal, and both exclude pauses. A resumed checkpoint records total playing time accumulated before and after the save, plus the active duration for the recorded segment since resume (startKind: "checkpoint"). Score, clears, placed pieces, and misses are also cumulative checkpoint totals. Opening an already-finished checkpoint does not create another result. Restart starts a fresh attempt. The cumulative total is required in local and published result records; missing totals are rejected without migration. The local history retains the most recent 200 results. A “Fresh attempt” label describes the stored start kind; it does not prove that the attempt was never interrupted or resumed through another path.

Published finished-run records contain one setup: seed, starting level, rules version, RNG version, and initialBoard (one topology fingerprint, occupied cells, and colors). Every initial board includes the required integer subdivision, including 4 for the default board. Empty arrays represent the default starting board. An optional publishedScenario pins the source AT URI and CID; the embedded layout keeps the result self-contained. Publishing a result does not publish a separate scenario.

Challenge identity is computed from setup when displaying or indexing results; it is not stored in the record, and seeds do not distinguish score categories. Ending level is derived from starting level and faces cleared. Continuous timing is part of the current rules version, rather than a separate run field. RNG version remains explicit. placedPieces names the placement counter, segmentPlayingTimeMs is playing time since the recorded start/resume, and totalPlayingTimeMs includes earlier play. endReason is strikes or jammed; the UI supplies the explanatory sentence. The current rules end strikes at three misses and jammed runs before that limit.

The record's $type identifies its schema; there is no second numeric result version. Future incompatible public schemas should use a new Lexicon NSID. Local storage documents and core checkpoints retain their own formats. Old run payloads are rejected without migration. Run records have no reserved replay field; a replay format will be designed when verification is implemented.

All results are unverified. A score, CID, account author, or valid record does not prove how the score was earned, who played, whether play was solo, or whether the result is complete. Records produced by this feature contain no move history or replay reference and preserve no evidence about moves between checkpoints. The AppView displays both indexed records and separately stored anonymous submissions on public leaderboards; neither source provides verification or moderation. Checkpoints and named saves remain local. Publishing them is deferred until a separate design defines the source and parent checkpoint history needed to interpret resumed results.

Each local run ID maps to a deterministic valid record key. Before creating a record, the app checks that key; retries and concurrent attempts accept an existing record only when its normalized payload matches the intended result. This avoids duplicate records after an unclear network response without storing a separate local publication map. If the key already holds a different result, the app reports a conflict and leaves the existing record unchanged. Deletion uses the loaded CID as an AT Protocol swap guard. Removing a record cannot recall copies others made.

Submitting the same local run as Guest and later publishing it from the same browser can replace the Guest leaderboard entry. The browser sends its run ID and the exact URI/CID returned by PDS publication to the AppView. The AppView links only a Guest row owned by that browser's HttpOnly cookie, and hides that row only after indexing confirms an active account's exact URI, CID, and full canonical run payload. Links wait in SQLite across restarts when indexing is delayed, and are checked by live commits, PDS snapshots, and account reactivation. A mismatch or inactive account leaves the Guest result visible. The confirmed Guest row remains as a hidden receipt after public deletion, so retrying the same run cannot make a second Guest entry.

The browser saves unaccepted linking requests locally and retries them across page navigation and app reloads. Failed requests use increasing delays from 5 seconds up to 60 seconds, with no retry limit, and retry when connectivity returns. These requests never republish a run. Once the server accepts a link, the existing ingestion process waits for its matching publication.

Collections and permissions #

Checked-in Lexicons use:

  • net.oscillatory.radcohesion.scenario
  • net.oscillatory.radcohesion.run

OAuth requests atproto and create/delete permission for the scenario and run collections. Existing sessions created before run permissions were requested receive an actionable error when a publish or delete is attempted; sign out and sign in again, then approve the updated access request. The app does not request game-save writes or Bluesky social permissions. Records are parsed locally even if the PDS does not know these Lexicons. Record creation leaves server validation optional rather than requiring an unpublished schema to be resolvable.

Node deployment #

Build with the exact public HTTPS address, including the deployment subdirectory:

SITE_URL=https://oscillatory.net/rad-cohesion/ npm run build

Build and run the full Node application at that address. SvelteKit serves oauth-client-metadata.json, the OAuth callback page, prerendered game pages, and the AppView APIs from one origin. The public metadata URL is the OAuth client ID; the browser OAuth client needs no client secret. Changing the host or deployment path requires rebuilding with the new SITE_URL.

For local OAuth, open http://127.0.0.1:5173/game/ and keep the same host and port through the callback. The development app can sign in to and publish directly to a real PDS; it does not require a deployed app, a local PDS, or a public metadata URL. The SDK's loopback client ID uses http://localhost while its callback uses 127.0.0.1; those hostnames have separate browser storage, so the app offers a link to the same page at the IP address when opened on localhost. The link preserves the port, app base, query, and fragment. Click Sign in again after switching; browser saves and the active game remain on the original hostname. AT Protocol's localhost client-ID exception is optional for authorization servers, so local OAuth availability depends on the provider. See the official OAuth specification's localhost client development section.

HANDLE_RESOLVER configures the trusted handle resolution service at build time (default https://bsky.social). Public scenario reads resolve the author's DID to its current PDS; they do not assume every account is hosted by Bluesky. OAuth callback and metadata responses are excluded from service-worker caching. Local gameplay and libraries continue to work offline; authentication, publication, and downloading public scenarios require connectivity.

To make the Lexicons discoverable on the network, publish their JSON definitions as com.atproto.lexicon.schema records in the chosen schema owner's repository, with each NSID as its record key. Then configure:

_lexicon.radcohesion.oscillatory.net TXT "did=did:plc:<schema-owner>"

The namespace authority is net.oscillatory.radcohesion, whose reversed domain is radcohesion.oscillatory.net. Publishing schemas and setting DNS are deployment steps; checked-in JSON does not perform them. See the official Lexicon publishing guide and OAuth guide.

Next slices #

The AppView can backfill and tail published runs and scenarios into SQLite when configured with a Jetstream API key. It serves paginated best-per-player and all-run leaderboards grouped by shared challenge identity, and accepts anonymous results without an AT Protocol account.

  1. Add scenario discovery, eligibility controls, and moderation workflows.
  2. Define a replay format and deterministic verification for separately verified results.

A list of records from one PDS is not a global leaderboard. AT Protocol stores and distributes records; ranking, discovery, and eligibility still need an app service and explicit rules.

Verification and deployment status #

The OAuth browser specs exercise the real browser SDK against mocked identity, OAuth, and PDS endpoints, including PKCE and signed DPoP requests. They do not use a real account. No real-account sign-in or publishing test has been performed. Local real-PDS use does not require deployment when the provider supports the protocol's optional localhost client-ID exception. A hosted app still needs its public metadata URL in SITE_URL before OAuth can be used from that hosted origin. The app does not publish Lexicons or configure DNS during a build.