geometric packing game (tetris-ish) on an icosahedron. save boards and completed runs as records in your atproto PDS oscillatory.net/rad-cohesion
rad-cohesion docs architecture.md
15 kB · 256 lines
Markdown

Architecture #

Rad Cohesion is a SvelteKit app with a TypeScript simulation, browser rendering and local persistence, and a Node backend for public discovery and leaderboards. The browser owns game sessions, including multiplayer race boards. The backend indexes publications, accepts Guest summaries, and coordinates races; it does not simulate or verify runs.

Read this after the README. Use Deployment for hosting and operations, the session reference for engine contracts, and records and APIs for public data. Future work belongs in the roadmap.

Code boundaries #

Area Responsibility
src/core/ Topology, pieces, placements, orientation, rules, RNG, scenarios, and sessions. No DOM or Three.js dependency.
src/view/ 3D and 2D displays, picking, camera gestures, and scenario previews.
src/ui/ and src/routes/ Svelte HUD, dialogs, canonical pages, and thin API endpoints.
src/main.ts mountGame() browser controller: input, local simulation clock, rendering, audio, and save workflows.
src/persistence/ Validated browser repositories for checkpoints, scenarios, and finished runs.
src/runtime/ Navigation recovery, finished-run recording, and pending-result retries.
src/records/ Shared public-record validation, mapping, and identities.
src/atproto/ Browser OAuth, PDS reads/writes, and Guest-publication linking.
src/multiplayer/ Shared race protocol/observations, browser transport, local race timing, and same-tab checkpoints.
src/audio/ and src/service-worker/ Audio preferences/playback and offline caching.
appview/src/ SQLite index, ingestion, queries, and Web Request/Response handlers.
scripts/ Development and production server bootstraps.

Simulation and boards #

GameSession is the mutation boundary. It owns ordered commands, simulation time, and the engine. HUDs and renderers receive its read-only GameView; controllers dispatch commands instead of modifying occupancy or scores. The caller supplies a monotonic clock. Continuous timing processes every elapsed deadline, including delayed frames; only explicit pause freezes game timers. Rendering does not define gameplay timing semantics.

board.ts welds triangular face subdivisions into shared vertices and edge adjacency. Shape and subdivision determine the topology fingerprint and stable cell IDs. Keep the icosahedron's canonical vertex/face ordering stable: saved layouts depend on it. Supported shapes and size limits live in polyhedron.ts and subdivision.ts. Board metadata is required in stored artifacts even though new-game URLs may omit defaults.

placements.ts precomputes embeddings of triangular-lattice piece templates. Shared vertices must agree and cells cannot overlap; boundary vertices may meet when a piece wraps around a corner. orientation.ts transports direction across faces. Hover can relocate a footprint's root without silently changing its heading or reflection. Placement-index memory grows with k²; the current ceiling is 10. Measure the full workload before raising the cap.

prepareGameBoard takes a defensive, immutable snapshot of the topology and placement index, validates placement identities, and computes both fingerprints once. Its PreparedGameBoard also owns the shared placement-ID lookup. Sessions created with GameSession.fromPrepared reuse those objects while retaining their own occupancy, RNG, timers, rewards, focus, and orientation caches. The raw GameSession constructor prepares its inputs for callers that do not need reuse. Preparation does not change placement ordering, fingerprint values, or saved record formats.

SceneView renders the same game in 3D or through a Lambert azimuthal equal-area 2D projection. Picking inverts the display back to the original flat surface. Each display retains its camera/pan/zoom; changing projections leaves the simulation unchanged. Occupancy is authoritative in the engine, and colors are render metadata. Scenario listings draw static thumbnails through a shared renderer; detail pages have an independently disposed interactive preview.

Data model and ownership #

Structure Meaning
BoardScenario Starting cells/colors on an explicit board. Name and save time are separate metadata.
ChallengeDefinition Scenario, starting level, rules, RNG version, and timing policy; defines a score category.
GameSessionSnapshot Complete live state needed to continue an attempt.
FinishedRun Attempt setup and cumulative results, without a move history.
Public scenario/run record Lexicon-compatible publication mapped from validated local/core data.

Challenge identity includes the layout and gameplay configuration, but excludes seed and publication URI. Equivalent layouts share standings at the same starting level. Runs embed their initial boards, so deleting a source scenario does not delete its runs.

Named checkpoints use GameSaveRepository; scenarios and finished runs have separate repository boundaries. Current adapters use localStorage. They validate storage envelopes and metadata; GameSession.restore validates the complete checkpoint before changing live state. Camera and audio preferences are not checkpoint state. There is no automatic named save or account synchronization. Save pauses active play; Save as new preserves cumulative playing time while creating a separate entry. Local history retains the newest 200 finished runs.

Local data belongs to the browser profile and origin, not the current account. Signing in neither uploads nor hides it. Publication creates a separate public record. Imported scenarios are independent editable copies. A game launched from a public scenario retains its exact URI/CID beside the embedded challenge, so checkpoint resume works without fetching the publication again.

Routes and recovery #

All paths below are relative to the deployment base.

Route Purpose
/game/ Timed or Free Play, setup, and save dialogs.
/multiplayer/ Invite-based 2–4 player score-race lobby, play, and observation.
/leaderboards/ Layout directory or exact standings selected by query.
/scenarios/ Public search and pagination.
/scenarios/published/?uri=…&cid=… Exact scenario revision, preview, and launch actions.
/players/<DID>/ Active author's indexed scenarios and runs.
/library/ tab=saved, tab=scenarios, or tab=runs; local items and current account publications.
/oauth/callback/ Browser OAuth return.

The root redirects to /game/, preserving query and fragment. Explicit launches include a fresh game, mode/seed/board change, checkpoint resume, or scenario or challenge selection. Their query parameters are consumed after a successful launch, returning the address to /game/. Supplied board metadata must match referenced artifacts. Unsupported rules/RNG versions are rejected; failed launches do not fall back to an unrelated empty board.

Leaving the game pauses timed play and stores the checkpoint and finished-run recorder state in sessionStorage, with an in-memory fallback. Play, reload, and OAuth return restore this same-tab recovery paused in Timed mode or ready to edit in Free Play. An explicit launch replaces recovery even if validation fails. Named checkpoints remain independent in localStorage. Pending results that failed to save survive same-tab game replacement and retry with their original IDs to avoid duplication.

Browser lifecycle #

GameScreen.svelte dynamically imports mountGame() after mounting. Server rendering creates no game, WebGL renderer, audio context, or storage repository. mountGame(host, options) scopes DOM queries to its host and returns the session, view, audio, and idempotent disposer. Each HUD has its own presentation store; Svelte updates markup while the controller handles simulation and raw pointer input. Importing the controller has no startup side effects.

Leaving the route cancels animation frames and listeners, removes observers, releases gestures and graphics resources, aborts audio downloads, and closes audio. Canceled startup and partially failed constructors clean up too. Repository work already started can finish, but must not update a disposed screen. Restoring a checkpoint invalidates display caches and resets event cursors without replaying historical sounds.

The native service worker caches built/static assets and known prerendered pages. Navigation tries the network with a cached fallback. APIs and OAuth are excluded. Updates wait for existing windows to close; cache names and cleanup are scoped to the installation path.

Backend and indexing #

One Node process applies migrations, starts one ingestion worker, and serves the generated SvelteKit handler. Builds do not open SQLite or start ingestion. API route modules wrap shared Web Request/Response handlers and opt out of prerendering and trailing-slash redirects. Shutdown drains requests, stops the worker, then closes SQLite.

Jetstream subscriptions request only commits for the two application collections. Initial archive discovery finds candidate DIDs; current PDS snapshots reconcile their records before steady live indexing. Discovery and live cursors are separate. An expired live cursor catches up through the archive from the latest saved live cursor with a fixed endpoint, without skipping gaps.

Record changes and cursor progress commit together. Invalid replacement records remove the former indexed version. Completed PDS snapshots retain revision fences so older stream events cannot overwrite them; newer commits fence out stale in-flight snapshot responses. Multiple events in a newer repository revision still all apply.

Account status is checked hourly and full records daily or after a matching commit. PDS requests are globally serialized through a deduplicated queue, with failed authors retried fairly. DID/PDS reads use guarded HTTPS: public DNS addresses only, connections pinned to validated addresses, normal hostname certificate checks, no redirects, a shared 15-second deadline, and 2 MiB response limits. Lookup failures preserve last-known state rather than claiming deletion. Deactivation hides rows, confirmed deletion removes them, and reactivation triggers a fresh listing. RepoNotFound marks an account inactive with an unknown reason; it alone does not prove deletion. Revision-less PDS checks are best-effort rather than atomic snapshots.

SQLite stores validated records plus query columns. Rankings are computed in SQL, without a leaderboard cache table. Guest data is separate from ingestion-owned PDS rows. Neither source proves a score was earned. The record/API reference covers ranking and Guest linking.

Multiplayer score races #

The Node coordinator in appview/src/multiplayer/ attaches a WebSocket endpoint to the existing HTTP server, independently of SQLite ingestion. It owns the roster, readiness, common seed/setup, scheduled start/deadline, reconnect policy, and reported results. Rooms live in bounded process memory and are not restored after a server restart. Neither race observations nor results become published single-player records.

Each browser runs its own GameSession through the race runtime. Board/index preparation happens in the lobby; gameplay uses match-relative monotonic time and catches up after late frames or reconnect. Pause/restart are unavailable. Only the local browser retains the complete checkpoint and RNG state.

Hosts can add or remove bot seats in the lobby. Bots prepare before becoming ready and run headless GameSessions on the backend, with a worker per bot-enabled room keeping preparation and simulation off the coordinator thread. Each worker prepares one immutable board/index context for all its bots; separate workers have separate JavaScript heaps and do not share that context. The browser race lobby caches one prepared context, also reused for checkpoint restoration. They share the room's scenario, seed, start and deadline, and send the same complete observations used for human opponents. Human simulation authority stays in the browser. Bot identity is assigned by the coordinator; bots never become hosts or receive browser reconnect credentials.

The initial decision policy is deliberately simple: pick the first legal placement in stable index order. To change it, start with FirstLegalBotController.choosePlacement in botController.ts. Normal-input mapping lives separately in botPlacementCommands.ts; worker preparation, preview pacing, observations and match timing belong to the participant runtime. The controller receives the public game view, without the RNG seed or hidden future pieces.

Single-player and the local race board share gameplay HUD components and their presentation derived from GameView: piece time, level progress, strikes, piece previews, saved pieces, and placement controls. The race deadline and roster stay in the multiplayer shell, separate from the per-piece countdown.

Opponents use OpponentBoardView, which consumes a narrow BoardDisplayState instead of a session or placement index. Complete observations contain the visible board, current preview/focus, counters, and timers, without the queue or RNG internals. They carry per-board sequence numbers; stale observations are discarded and reconnect supplies the latest frames. Camera controls remain local to the observer. Static geometry positions can be shared; mutable colors and graphics-resource lifetimes remain per view.

Room-state revisions order lifecycle changes independently of observation sequences. The transport validates and bounds reports and coalesces observations for slow consumers. A private per-seat token authorizes reconnect; invite codes alone cannot reclaim a seat. An active reconnect also requires a compatible local checkpoint, otherwise the seat forfeits and can watch read-only.

Scores, timing, and game-rule compliance are client-trusted. The server freezes early game-over reports and gives remaining clients a five-second reporting grace after the fixed deadline. A missing final report retains the last accepted score, or zero if none; forfeits have no rank. The match finishes early if every scoring player ends, or if exactly one remains active with a strict lead over all frozen opponent scores. Inactivity and a jammed placement alone do not freeze a score; ties and trailing survivors keep playing. Future battle coordination and stronger verification remain roadmap work.

Verification boundaries #

Unit tests cover topology, simulation, snapshots, persistence, and views. Server tests use temporary databases and offline ingestion/transport fixtures. Browser tests cover real navigation and a test-only fixture with window.__rad; that adapter is absent from production. OAuth tests use mock identity/PDS endpoints with the real SDK. PWA tests build under a subpath and exercise installation and offline behavior. Test results belong in check output rather than permanent counts in these docs.