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 placementcapture.mp3-- capturecheck.mp3-- move that gives check (optional, some players find this annoying)game-end.mp3-- checkmate, stalemate, resignation, drawnotify.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, playmoveorcapturebased on whether the move was a capture. chess.js move result includes acapturedfield.applyOpponentMove(): playnotifywhen 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
applyMoveto return the move object (which has acapturedfield), 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
lastMoveAtorcreatedAt. - Games in
waitingstatus (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.jsonexists 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 |