geometric packing game (tetris-ish) on an icosahedron
rad-cohesion README.md
24 kB · 231 lines
Markdown

Rad Cohesion #

A browser puzzle about packing tetriamonds onto one continuous, flat-faced icosahedron. On the default board, fill a 16-cell face to clear it. Pieces can cross every original face boundary.

Run #

Requires Node.js 24.16.x and a browser with WebGL.

npm ci
npm run dev

Open http://127.0.0.1:5173/game/, then click Start. Use http://127.0.0.1:5173/game/?seed=1234 for a reproducible piece sequence. Restart repeats that run’s original seed. An explicit fresh game launch without ?seed= chooses a random seed; Play and reload restore an in-progress game paused when one is recoverable. Use the IP address for local OAuth so the callback and sign-in share browser storage.

The shared header links to Play, Leaderboards, Scenarios, and Library. The Leaderboards page opens a directory of board setups with submitted results, grouped by board subdivision and ordered by latest activity. Each setup lists populated starting levels and links to exact standings. Default-game and scenario links from results continue to open exact standings. Scenarios lists indexed public setups, newest first, with name search and pagination. Library has Saved games, Scenarios, and Finished runs tabs. Play opens a new timed game when there is no in-progress game; otherwise it restores the current game paused. The game toolbar contains audio, pause, restart, mode, and save controls. Save game opens a naming form. Resume, delete, and finished-run history are on Library (/library/?tab=saved and /library/?tab=runs).

Use Library → Scenarios to manage local and published board setups. Save scenario in Free Play opens a naming form; the scenario list and challenge controls live on /library/?tab=scenarios. New game in the game toolbar creates an empty board in Timed or Free Play mode, without an account or publication. Timed mode also offers a starting level.

The New game setup also accepts an edge subdivision k from 1 to 16; the new boards default to k=4 (320 cells). See board subdivision for the k=1 behavior and current resource ceiling.

Author links on scenarios and leaderboards open a public player page with that account's indexed published scenarios and finished runs. Player addresses use DIDs, so changing a handle does not change the link. These pages do not include local saves or Guest submissions.

Leaving play for a library pauses and records temporary recovery for this tab. Play restores that game paused, including after a reload or OAuth sign-in redirect. This recovery is separate from named checkpoints. Starting an explicit new game, changing mode, choosing a seed, resuming a saved game, or launching a scenario replaces the recovered game. A failed explicit launch clears the prior recovery too. See navigation and recovery for routes.

npm test           # topology, embeddings, game transitions, raycast/preview geometry
npm run lint      # Oxlint and all generic anti-slop rules
npm run typecheck # TypeScript checks for source and browser tests
npm run build     # TypeScript checks and production build
npm run preview   # run the production Node application
npm run test:server # focused AppView and HTTP API tests
npm run test:shutdown # built Node server SIGTERM smoke test (run after build)
npm run test:all    # unit tests, build, AppView tests, and shutdown smoke test

Anti-slop is vendored in tools/oxlint/anti-slop/ and configured in .oxlintrc.json. Its provenance and pristine-file checksums are recorded beside the plugin. Keep oxlint and @oxlint/plugins pinned to the same version when updating them. The HUD and camera unit tests use Happy DOM to exercise real DOM elements. Browser fixtures have their own types and are checked through tsconfig.browser-tests.json as part of npm run typecheck.

Install and offline play #

The production build includes a web manifest, app icons based on the header logo, and a service worker that caches the game and all audio. Run the full Node application over HTTPS, then use Brave/Chrome’s Install app menu. On iOS, use Safari’s Share → Add to Home Screen. Localhost is allowed for testing; a plain HTTP LAN address on a phone is not a secure context and cannot install the app.

Use npm run build followed by npm run preview to test installation on localhost. npm run dev does not enable the production service worker. After the first online load finishes caching, the game works offline, including free play and audio. The shared audio preference is stored as rad-cohesion:audio; checkpoints and scenarios saved through the game forms persist in this browser. Updates activate after all app windows/tabs are closed, so a new deployment never reloads a game in progress.

For a local subdirectory check, use BASE_PATH=/your-path npm run build and the same BASE_PATH when running the production server. For public deployments, use SITE_URL with the full path. The manifest, icons, and service worker use that base path. npm run test:pwa checks browser installability and offline play with audio against a production build under a subdirectory.

Set SITE_URL to the full public address, including any subdirectory, when building. This sets SvelteKit’s deployment base and the absolute canonical, Open Graph page, and preview image URLs in the generated HTML:

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

Self-hosters can substitute their own address. Alternatively, copy .env.example to .env.local, set SITE_URL there, and run npm run build. A missing trailing slash is added automatically. Use SITE_URL for public deployments; BASE_PATH is intended for local subdirectory checks.

Without SITE_URL, normal local builds still work. Image metadata uses the deployment base path, and canonical/absolute page metadata is omitted. Set SITE_URL before publishing for reliable link previews. This is a build-time setting; changing it requires rebuilding the application.

The preview source is src/assets/social-preview.png (1200 × 630). Each production build emits _app/immutable/assets/social-preview.<hash>.png and uses its URL in both image tags. Replacing the source image changes its content hash and URL automatically.

Run the production Node application at the configured path. Canonical pages include /game/, /leaderboards/, /library/?tab=saved, /library/?tab=runs, /library/?tab=scenarios, /scenarios/, /scenarios/published/?uri=…&cid=…, and /oauth/callback/ (relative to that path). The root / redirects to /game/. Sign in through the shared header. On localhost, the sign-in dialog links to the same page at 127.0.0.1; click Sign in again there. Browser saves remain separate for the two hostnames. Preview crawlers can read prerendered page metadata from the Node application without running the game. A sharing service may also cache the page metadata; if a new draft still shows an older card after deploying, sharing a URL such as ?preview=2 can prompt a fresh metadata lookup. Existing published cards may retain their original image.

Controls and rules #

Input Action
Hover a small triangle Move the piece’s center toward the pointer
R Rotate clockwise by 60° (Shift+R: counterclockwise)
F Flip the piece
Space or left click Lock early; blocked placements are rejected
3D / 2D Switch board display while keeping the same game state
Left drag Orbit in 3D; pan in either flat view
Scroll / pinch Zoom
Recenter or C (flat views) Center the current aim and reset zoom
Reset view Favor the current aim; the 3D camera is kept across toggles
P / Esc / Pause button Pause or resume; the board is hidden and the timer is frozen
Restart Clear the board and restart the seeded sequence

The 3D / 2D button switches between the globe and a flat circular board. 2D view radially maps each flat icosahedron face onto a sphere, then uses the spherical Lambert azimuthal equal-area projection. It preserves spherical area, with increasing shape distortion near the rim. The exact antipode lies on the rim; the background outside the circle cannot be picked.

The highlighted parent face stays near the projection center: when the aim enters a different parent face, the pole eases to the small cell opposite that face's center over a short transition, keeping the same surface point under the pointer or center aim. Reduced-motion settings apply the target immediately. Hover and tap picking invert the same projection and pass the original flat-surface point to the game, so piece orientation carries across face seams as it does in 3D. The selected parent face has a soft highlight and brighter borders to make its large triangular region easier to follow.

Drag to pan, use the wheel or pinch to zoom, and use center aim on desktop to keep a piece under the center while panning. Choose Recenter (or press C) to put the current surface point in the middle and reset zoom. Reset view resets zoom and centers the current aim on its favored parent face. Each mode keeps its own view position when switching, while the game state stays shared.

The ghost is mint when legal and red when blocked. It pulses slowly at first, then increasingly rapidly toward the deadline, in mint green when legal and red when blocked. Its opacity also builds toward solidification; it stays visible between pulses and automatically attempts to lock at timeout. Moving off the surface retains the last placement. You can place directly anywhere on the surface; there is no falling or stacking.

A lock completes all full parent faces simultaneously: at level 1, one face earns 100 points, two earn 400, three earn 900, and four earn 1600. Clear points are multiplied by the level before the clear, so crossing a level boundary never changes that clear’s multiplier. Cells on other faces stay in place, even if they belonged to the same piece. Start at level 1 and advance every five cumulative faces cleared. The sidebar shows the level, score multiplier, and progress toward the next level. Every level shortens subsequent piece timers by one second, starting at 10 seconds and stopping at four seconds; levels and score multipliers keep increasing beyond that floor. A level-up accents the level display briefly and appears in the clear feedback. The placement countdown is a shrinking bar above the icosahedron, with no numeric seconds display; it hides during pause, before start, after game over, and in free mode. Piece colors and saved-piece reward rules stay fixed. An invalid piece at timeout costs one strike; manual invalid attempts leave the piece and timer active and play a rejection sound. Three strikes end the run. A jammed next shape ends it only when no saved piece can fit.

Audio #

The Audio button in the game toolbar toggles music and sound effects, with short fades and a remembered preference. Music starts only after a user gesture; a remembered On preference starts on the next click/tap/key interaction. It can play on the start screen, pauses with the game or a hidden tab, resumes at the same position, and pauses when the run ends. Restart and level changes keep the same song position. The icon shows speaker waves when enabled and a crossed speaker when muted; its accessible name is Audio, with the state exposed through aria-pressed. A failed load offers retry through its accessible description and tooltip, leaving the game playable.

The game lazily fetches src/assets/theme.mp3 (about 659 KiB) on first activation. The original theme.wav (about 13.2 MiB, 72 seconds) is retained as the master and is not included in the production build. The shipped version uses high-quality variable-bitrate MP3 for broad browser compatibility. Playback decodes it once into a Web Audio buffer and loops that buffer without scheduling a new download or source each time. The loop uses the whole exported track. Successful placements play src/assets/placement.mp3 (about 1.5 KiB, converted from placement.wav), once per placed piece, including automatic locks, face clears, and saved pieces. Clearing one or more faces also plays src/assets/clear.mp3 once per clearing placement. Invalid manual attempts play src/assets/invalid-placement.mp3, converted from invalid-placement.wav. Music and all effects share 30% playback gain, preserving the volume balance of the exports. Invalid timeouts do not play the rejection sound. All four clips are decoded once after audio activation; effects use independent one-shot sources so they can overlap without restarting the song. Muting or pausing cancels active effects; the shared audio preference uses rad-cohesion:audio.

To regenerate the compressed asset after changing the song:

ffmpeg -i theme.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/theme.mp3
ffmpeg -y -i placement.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/placement.mp3
ffmpeg -y -i clear.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/clear.mp3
ffmpeg -y -i invalid-placement.wav -map_metadata -1 -codec:a libmp3lame -q:a 3 src/assets/invalid-placement.mp3

Saved pieces #

Simultaneous multi-face clears also bank one consumable piece: 2 faces → 3-tile Triamond, 3 faces → 2-tile Diamond, 4 faces → 1-tile Moniamond. Single-face clears award no item. There is one connected shape at each of these sizes up to rotation/reflection, so no shape-selection dialog is needed.

Click Use beside a saved piece to activate it during play. Rewards stack, have no storage limit, and are consumed on activation. A reward temporarily replaces the current piece; its normal timer and orientation are preserved and its queue stays unchanged. The reward gets a fresh normal-length deadline in timed play. After the reward locks or misses, the suspended piece resumes with its previous remaining time. Only one reward can be active at once. Free mode hides and disables saved pieces because every shape is available directly. Smaller pieces never appear in the timed random queue: multi-face clears are their only source in timed play. Free mode allows all six shapes to be selected and placed repeatedly without earning or spending inventory. Restart/Clear board empties the inventory.

Saved pieces can rescue a jammed next shape: play continues with a “Use a saved piece” prompt if an earned item still fits. A completely full board offers no legal use. The one-tile piece fits any empty triangle, regardless of the selected heading or reflection.

Free mode #

Desktop defaults to hover to position the piece, drag to orbit in 3D (or pan in either flat view), and click/Space to place. Toggle Center aim: On above the board to use a fixed center preview on desktop too: drag to reposition, click anywhere on the board or press Space to place. Mouse movement alone never rotates the 3D view. Toggle it off to restore hover placement. On touch devices, the piece aims at the screen center: drag the board beneath it, lift to stop, then tap to place the preview. Dragging starts immediately; no long press is needed. A drag, long hold, canceled gesture, or pinch never places a piece. Pinch zoom and the on-screen rotate, flip, and Place buttons work without a keyboard.

The 3D / 2D button changes only the board display. It keeps the same board, score, queue, in-flight piece, and orientation; the preview follows the active pointer or center aim. 2D view supports mouse hover and drag pan, wheel zoom, and touch pan/pinch. Its pole follows the highlighted parent face while preserving the aimed surface point and zoom. Recenter or C centers the current aim and resets zoom. Reset view centers the current aim at default zoom; switching back to 3D returns to the same camera orientation and zoom.

The responsive layout keeps stats in a compact top strip and pieces and placement controls in one tray; timed mode also shows the next pieces and three saved-piece cards. The tray sits beside the board on desktop and below it on phones. On phones, the action buttons sit directly below the board, followed by the current/next pieces or shape selector, then the saved pieces. The board uses the remaining viewport height, including when mobile browser chrome changes. Tap a saved-piece card to use it. Controls and reward targets stay at least 44 pixels tall. At 320px widths, the brand keeps its icon and accessible name while hiding the visual wordmark to make room for these controls. How to play opens controls and reward rules without moving the board; free-mode coordinates are under Position details.

The discrete footprint stays near the center and can remain red or invalid rather than changing your selected orientation. Devices with both mouse and touch switch the aim according to the input used on the board.

Use the mode control in the game toolbar to switch between timed and free play, or open http://127.0.0.1:5173/game/?mode=free. Select Ribbon, Hook, Crown, Triamond, Diamond, or Moniamond in the piece tray. Hover, R, F, click/Space, camera rotation, and zoom use the same controls as timed play. The chosen shape repeats after each placement; there is no countdown, random queue, strike penalty, or jammed-board game over. Blocked placements stay red and play the rejection sound when you attempt to place them. Full faces still clear normally.

Clear board removes all occupancy and keeps the selected shape. The diagnostic panel shows the pointer cell, actual root cell, face, rotation, reflection, and target cells; cell/face IDs are zero-based so you can refer to precise examples. Timed mode returns to the normal game on a fresh board.

Structure #

  • src/core/board.ts: integer barycentric vertex welding, outward cells, edge adjacency, parent faces.
  • src/core/pieces.ts and placements.ts: triangular-lattice templates and precomputed embeddings for all 500 anchors and six corner bijections. Both local cell and vertex correspondences must remain consistent and injective.
  • src/core/orientation.ts: carries the selected direction across folded face edges and finds the nearest whole footprint with exactly that direction and reflection parity. Hovering can relocate the root triangle, but never substitutes another orientation. Direct jumps use cached shortest face routes, with inverse routes for return jumps.
  • src/core/game.ts and random.ts: deterministic state transitions, seeded sequence, countdown, atomic clears, scoring, and game over; no Three.js dependency.
  • src/view/: batched 3D cell geometry, Lambert azimuthal equal-area rendering with inverse radial picking, shared overlays, and unrestricted trackball rotation with bounded zoom. Each display keeps its own view transform.
  • src/routes/: SvelteKit game route and local saved-game/scenario libraries.
  • src/ui/: Svelte HUD, SVG piece previews, game lifecycle wrapper, and library layout.
  • src/main.ts: mountGame() browser controller; imports have no startup side effects.
  • src/service-worker/: offline caching for built assets, audio, and known static pages.
  • src/audio/gameAudio.ts: shared audio preference, master mute/pause, looping music, and cached placement effects.

GameSession owns mutable game state, validated commands, complete JSON snapshots, and simulation time. Renderers and the HUD receive its read-only GameView. See the session contract for API examples, command order, snapshot compatibility, and timing.

Occupancy is authoritative in the game state. Rendering colors are metadata only. The browser uses continuous timing: all elapsed deadlines are processed, independently of display frames. Delayed frames and background tabs can expire multiple pieces; only an explicit pause freezes the countdown. The 100 ms lock feedback and 300 ms clear/strike feedback suspend the countdown.

Movement snaps to translated footprints on the triangular grid. The pointer guides the footprint's center and can cover any of its four triangles; it need not sit on the designated root. R and Shift+R rotate in opposite 60-degree steps; F reflects without changing the chosen heading. Occupied footprints remain red, and unavailable embeddings are invalid rather than automatically rotated. Carrying a direction around a five-valent corner can still turn it by 60 degrees because of the surface's curvature.

Browser smoke tests #

npx playwright install chromium   # first run only
npm run test:browser

These tests use an isolated fixture to inspect game state while exercising the actual renderer and mouse/keyboard controls. The regular application does not expose test state on window. Coverage includes rotation/flip, deterministic restart, click versus drag, zoom/reset, automatic locking, seam placement, blocked previews, three strikes, and simultaneous face clearing. Unit tests also raycast every 3D cell, round-trip all 500 Lambert cell centers and inset boundaries, check pole behavior, and verify ghost geometry/color/opacity without launching a browser. Browser coverage checks 2D pixels, inverse picking, camera preservation, pan, zoom, and the narrow-screen toggle.

For manual playtesting, orbit to the rear and place there, hover near a thicker face boundary and rotate until the ghost crosses it, try a blocked placement, and confirm that a cleared face leaves occupied cells on its neighbors intact.

Prototype status #

Implements the v0.1 handoff with N=5, all three specified tetriamonds, two queued pieces, continuous seam placement, and simultaneous face clearing. Desktop mouse/keyboard, optional center aim, and touch drag/tap with on-screen piece controls. Local play needs no account or external requests; assets are served with the application. Optional atproto OAuth supports explicitly publishing scenarios and unverified finished-run summaries. Checkpoints and local result history remain in this browser; runs are published only after account-specific confirmation.

See accounts and protocol records for setup, data formats, privacy, deployment, and leaderboard data details.

The full SvelteKit Node app also runs the AppView, which backfills and indexes published runs and scenarios from Jetstream in SQLite. It serves public leaderboards for the default challenge and published scenarios, and accepts separately stored anonymous browser submissions. Every result is unverified.

Use the verification commands above to check the current checkout. The game renderer chunk may produce Vite’s advisory warning about exceeding 500 kB (about 171 kB gzipped).

Responsive browser coverage in tests/responsive.spec.ts checks timed and free play from 320×480 through desktop, including landscape, tablet, changing browser height, saved-piece activation, help keyboard controls, and keeping the board and all piece controls within the viewport.

SvelteKit architecture #

See the SvelteKit architecture for framework boundaries, route lifecycle, and the unified Node deployment.