Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Phase 6: Polish#

Priority: Lower

Quality-of-life improvements. Each sub-phase is independent and can be done in any order or interleaved with other work.


6a. Sound Effects#

Problem#

The game is silent. No audio feedback on moves, captures, or game events. This makes gameplay feel disconnected, especially when waiting for an opponent's move.

Implementation#

Sound set:

Lichess sounds are BSD-licensed. Use the standard set:

  • move.mp3 -- piece placement
  • capture.mp3 -- capture
  • check.mp3 -- move that gives check (optional, some players find this annoying)
  • game-end.mp3 -- checkmate, stalemate, resignation, draw
  • notify.mp3 -- opponent made a move (plays when it's your turn)

Place in static/sounds/.

Audio playback helper: src/lib/sounds.ts

const sounds = {
  move: () => new Audio('/sounds/move.mp3'),
  capture: () => new Audio('/sounds/capture.mp3'),
  gameEnd: () => new Audio('/sounds/game-end.mp3'),
  notify: () => new Audio('/sounds/notify.mp3'),
};

export function playSound(type: keyof typeof sounds): void {
  try {
    sounds[type]().play();
  } catch {
    // Browser may block autoplay; ignore silently
  }
}

Preload sounds on first user interaction to avoid autoplay restrictions.

Integration points:

  • handleMove() in the game page: after a successful move, play move or capture based on whether the move was a capture. chess.js move result includes a captured field.
  • applyOpponentMove(): play notify when the opponent moves.
  • Game over detection: play gameEnd.

Detecting captures: The current tryMove and applyMove functions in game-logic.ts return a boolean. To know if a move was a capture, either:

  • Change applyMove to return the move object (which has a captured field), or
  • Check if a piece was on the destination square before the move.

Simplest: change applyMove to return Move | null instead of boolean, and update callers.

Mute toggle: Add a sound toggle in the nav or game page. Store preference in localStorage.

Acceptance Criteria#

  • Piece moves play a sound.
  • Captures play a distinct sound.
  • Game-ending events play a sound.
  • Opponent's move triggers a notification sound.
  • Sound preference persists in localStorage.
  • Sound toggle is accessible from the game page.
  • No errors if the browser blocks autoplay.

6b. Rematch Button#

Problem#

After a game ends, starting a new game with the same opponent requires navigating to /play, entering their handle again, and sharing a new link. A one-click rematch with swapped colors is the expected UX.

Implementation#

UI: After game result display, add a "Rematch" button alongside the share section (Phase 5).

{#if game.result && !isSpectator}
  <!-- result display -->
  <!-- share section -->
  <button onclick={handleRematch} disabled={rematchCreating}>
    {rematchCreating ? 'Creating...' : 'Rematch (swap colors)'}
  </button>
{/if}

Logic:

async function handleRematch() {
  if (!auth.agent || !auth.did) return;
  rematchCreating = true;

  // Swap colors
  const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid;
  const newWhite = game.myColor === 'white' ? opponentDid : auth.did;
  const newBlack = game.myColor === 'white' ? auth.did : opponentDid;

  const result = await createGame(auth.agent, {
    white: newWhite,
    black: newBlack,
    status: 'waiting',
  });

  goto(`/game/${auth.did}/${result.rkey}`);
}

The rematch creates a new game in the current player's repo. The opponent needs to visit the link and join. The "Post to Bluesky" and copy-link UI on the waiting screen handles the invite (Phase 1).

Linking rematches: Could add a rematchOf field to the game record pointing to the previous game URI. Nice for history but not essential. Defer unless there's a clear use case.

Acceptance Criteria#

  • "Rematch" button appears after game completion for participants.
  • Clicking it creates a new game with colors swapped.
  • Player is redirected to the new game's waiting screen.
  • The opponent can join via the same invite mechanisms (link, post, DM).

6c. PGN Export & Analysis#

Problem#

Players can't download their game's PGN or analyze it on external tools.

Implementation#

Download PGN button:

Add to the game page (visible for any game, in progress or completed):

function downloadPgn() {
  const pgn = makePgn(game.chess, game.whiteDid, game.blackDid);
  const blob = new Blob([pgn], { type: 'application/x-chess-pgn' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `checkmate-blue-${rkey}.pgn`;
  a.click();
  URL.revokeObjectURL(url);
}

Analyze on Lichess button:

Lichess accepts PGN via URL for analysis. The import endpoint accepts POST requests with PGN data, but for simplicity use the paste-friendly URL approach:

function openLichessAnalysis() {
  const pgn = encodeURIComponent(game.chess.pgn());
  window.open(`https://lichess.org/paste?pgn=${pgn}`, '_blank');
}

Note: very long PGNs may exceed URL length limits. For games > ~100 moves, fall back to opening the Lichess import page and letting the user paste.

Alternative: use the Lichess API to import the game:

const response = await fetch('https://lichess.org/api/import', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: `pgn=${encodeURIComponent(game.chess.pgn())}`,
});
const data = await response.json();
window.open(data.url, '_blank');

This returns a Lichess URL for the imported game with full analysis board. No Lichess auth required for public imports.

Acceptance Criteria#

  • "Download PGN" button available on all game pages.
  • Downloaded file has correct PGN content with headers.
  • "Analyze on Lichess" button opens Lichess with the game loaded.
  • Both buttons work for in-progress and completed games.

6d. Abandoned Game Detection#

Problem#

Games where one player stops responding have no resolution. The waiting player is stuck.

Implementation#

Client-side convention: If lastMoveAt (Phase 4) is older than a threshold (e.g., 7 days), show an "Abandon" option to the active player.

{#if canAbandon}
  <button onclick={handleAbandon} class="text-sm text-text-secondary">
    Claim win (opponent inactive {daysSinceLastMove} days)
  </button>
{/if}
const canAbandon = $derived(() => {
  if (game.status !== 'active' || !game.isMyTurn === false) return false;
  const lastMove = record?.lastMoveAt || record?.createdAt;
  if (!lastMove) return false;
  const daysSince = (Date.now() - new Date(lastMove).getTime()) / 86400000;
  return daysSince > 7;
});

On abandon:

async function handleAbandon() {
  const result = game.myColor === 'white' ? '1-0' : '0-1';
  await updateGame(auth.agent, myRkey, {
    status: 'abandoned',
    result,
    resultReason: 'abandonment',
  });
  game.setStatus('completed');
}

Note: abandonment is not in the current resultReason known values. Add it to the lexicon:

"knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate",
                "insufficient", "repetition", "fifty_moves", "abandonment"]

No server-side enforcement. This is a client-side convention. A malicious client could ignore it. The bot project (separate) could handle automated abandonment detection with more authority.

Acceptance Criteria#

  • After 7 days of inactivity, the waiting player sees an "Abandon" option.
  • Clicking it marks the game as abandoned with the inactive player losing.
  • The threshold is measured from lastMoveAt or createdAt.
  • Games in waiting status (opponent never joined) can also be abandoned/cancelled by the creator.

6e. Mobile PWA#

Problem#

The app works in mobile browsers but doesn't feel native. No home screen icon, no "Add to Home Screen" prompt, no offline shell.

Implementation#

Create static/manifest.json:

{
  "name": "checkmate.blue",
  "short_name": "checkmate",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0f1419",
  "theme_color": "#0085FF",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Add manifest link to app.html:

<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#0085FF" />

Service worker (optional): SvelteKit can generate a service worker, but for a PoC the manifest alone provides the "Add to Home Screen" experience. A service worker for offline shell caching is a nice-to-have but not essential since the app requires network access for all core functionality.

Acceptance Criteria#

  • manifest.json exists with correct app name, icons, colors.
  • Mobile browsers show "Add to Home Screen" prompt.
  • App launched from home screen uses standalone display mode (no browser chrome).
  • Theme color matches the app's accent blue.

Files to Create#

File Purpose
src/lib/sounds.ts Audio playback helper
static/sounds/move.mp3 Move sound effect
static/sounds/capture.mp3 Capture sound effect
static/sounds/game-end.mp3 Game over sound
static/sounds/notify.mp3 Opponent move notification
static/manifest.json PWA manifest

Files to Modify#

File Changes
src/routes/game/[did]/[rkey]/+page.svelte Rematch button, PGN export buttons, abandon option, sound integration
src/lib/game-logic.ts Change applyMove return type to Move | null for capture detection
src/lib/types.ts Add abandonment to resultReason union
lexicons/blue.checkmate.game.json Add abandonment to resultReason knownValues
src/app.html Add manifest link, theme-color meta