Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Drop startingFen field, derive from PGN headers instead

Store the starting FEN in the PGN [SetUp] and [FEN] headers rather
than as a separate lexicon field. chess.js reads these headers on
loadPgn() automatically. Add daily challenge button with bot handle
via URL param, fix waitForOpponent re-init losing custom position,
and add variant rules doc for the bot.

authored by

Scott Hadfield and committed by
Tangled
07b7ab0c ada5675b

+240 -32
+147
docs/really-bad-chess.md
··· 1 + # Really Bad Chess 2 + 3 + A chess variant where each side gets a randomized set of pieces instead of the standard arrangement. Standard movement rules apply. Kings are mandatory, everything else is up to chance. 4 + 5 + Inspired by [Zach Gage's Really Bad Chess](https://apps.apple.com/us/app/really-bad-chess/id1109751921). 6 + 7 + ## Rules 8 + 9 + ### Pieces and movement 10 + 11 + Standard chess rules apply with one exception: **no castling**. The FEN always has `-` for castling rights since pieces are not in standard positions. 12 + 13 + Everything else works normally: check, checkmate, stalemate, pawn promotion, en passant, threefold repetition, fifty-move rule, insufficient material. 14 + 15 + ### Material values 16 + 17 + | Piece | Value | 18 + |--------|-------| 19 + | Pawn | 1 | 20 + | Knight | 3 | 21 + | Bishop | 3 | 22 + | Rook | 5 | 23 + | Queen | 9 | 24 + | King | 0 (mandatory, not counted) | 25 + 26 + For reference, standard chess material is 39 per side (Q=9, 2R=10, 2B=6, 2N=6, 8P=8). 27 + 28 + ### Players 29 + 30 + In human-vs-bot games, the human is always white. White always has equal or greater material than black. 31 + 32 + In human-vs-human games, both sides get the same material target. 33 + 34 + --- 35 + 36 + ## Board Generation 37 + 38 + Each side has exactly 16 pieces: 1 king + 15 generated pieces. 39 + 40 + ### Point budget 41 + 42 + **White:** 43 + 1. Pick a target value: random integer in [45, 68]. 44 + 2. Fill 15 piece slots to reach the target, allowing a variance of +/- 5 (actual total lands in [target - 5, target + 5], clamped to [40, 73]). 45 + 46 + **Black (human-vs-bot):** 47 + 1. Pick a disadvantage: random float in [0%, 13%]. 48 + 2. Black's target = floor(white's actual total * (1 - disadvantage)). 49 + 3. Fill 15 piece slots with the same +/- 5 variance. 50 + 51 + **Black (human-vs-human):** 52 + Same target as white, 0% disadvantage. 53 + 54 + ### Piece selection 55 + 56 + Per side (15 pieces, excluding king): 57 + 58 + 1. White is guaranteed at least 1 queen. Black is not. 59 + 2. Each side gets 1-5 pawns (random cap chosen per side). 60 + 3. Fill all 15 slots by picking random piece types, tracking the remaining budget. 61 + 4. As slots fill, constrain choices so the remaining budget can be spent across remaining slots (minimum 1 point per slot for pawns, maximum 9 for queens). 62 + 5. If the budget is overshot or can't be met, the remaining slots are filled with pawns. 63 + 64 + ### Placement 65 + 66 + 1. Kings on their standard squares: white king on e1, black king on e8. 67 + 2. Pawns ONLY on rank 2 (white) / rank 7 (black). Pawns cannot be placed on ranks 1 or 8. 68 + 3. Back rank (7 open slots after king): queens placed first, then other non-pawn pieces (shuffled). 69 + 4. Second rank (8 slots): overflow non-pawn pieces first, then pawns fill remaining slots. 70 + 71 + ### FEN format 72 + 73 + The generated position is stored as a standard FEN string: 74 + 75 + ``` 76 + <position> w - - 0 1 77 + ``` 78 + 79 + - Active color: `w` (white moves first) 80 + - Castling rights: `-` (no castling) 81 + - En passant: `-` 82 + - Halfmove clock: `0` 83 + - Fullmove number: `1` 84 + 85 + Example: `qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1` 86 + 87 + --- 88 + 89 + ## Daily Challenge 90 + 91 + A deterministic board is generated from the date so every player gets the same position on the same day. 92 + 93 + **Seed:** The string `really-bad-chess-YYYY-MM-DD` (e.g., `really-bad-chess-2026-04-14`) is hashed to a 32-bit integer using a simple string hash (djb2-style: `hash = ((hash << 5) - hash + charCode) | 0`). 94 + 95 + **PRNG:** The hash seeds a [mulberry32](https://gist.github.com/tommyettinger/46a874533244883189143505d203312c) generator. All random calls during board generation use this seeded PRNG, producing an identical FEN for every client on the same day. 96 + 97 + Daily challenge games use the human-vs-bot material rules (white advantage of 0-13%). 98 + 99 + --- 100 + 101 + ## AT Protocol Records 102 + 103 + ### Game record (`blue.checkmate.game`) 104 + 105 + Two fields distinguish a Really Bad Chess game from standard chess: 106 + 107 + | Field | Type | Description | 108 + |-------|------|-------------| 109 + | `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | 110 + | `startingFen` | string (max 200 chars) | The generated FEN string. Omitted for standard chess. | 111 + 112 + Both fields are optional for backward compatibility. A game record without `variant` or `startingFen` is a standard chess game. 113 + 114 + ### Challenge record (`blue.checkmate.challenge`) 115 + 116 + | Field | Type | Description | 117 + |-------|------|-------------| 118 + | `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | 119 + 120 + The challenge carries the variant so the opponent knows what they're accepting. The `startingFen` lives only on the game record. 121 + 122 + ### PGN headers 123 + 124 + When a game has a custom starting position, the PGN includes standard headers for non-standard start positions: 125 + 126 + ``` 127 + [SetUp "1"] 128 + [FEN "qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1"] 129 + [Variant "Really Bad Chess"] 130 + ``` 131 + 132 + The PGN is the source of truth for game state. Both players maintain their own game record with the full PGN. On load, both records are read and the longer PGN is used (since each record is one move behind 50% of the time). 133 + 134 + --- 135 + 136 + ## Bot Responsibilities 137 + 138 + When the bot receives a game with `variant: "really-bad-chess"`: 139 + 140 + 1. **Read the `startingFen`** from the game record and initialize the chess engine from that position. 141 + 2. **Play normally** from the custom FEN. Stockfish (or any UCI engine) accepts arbitrary FEN positions via the `position fen <fen> moves <moves>` command. 142 + 3. **Validate the FEN** if desired. The bot may reject games with invalid FENs (no king, pawns on ranks 1/8, etc.) but this is optional -- the client already validates via chess.js before writing the record. 143 + 4. **Difficulty scaling** works the same as standard chess. The bot's difficulty level (easy/medium/hard) is independent of the variant. The material asymmetry already gives the human an advantage; the bot's search depth controls the rest. 144 + 145 + The bot does NOT generate the FEN. The client (white player) generates the FEN and writes it to the game record. The bot reads it and plays from that position. 146 + 147 + For daily challenges, the client generates the FEN from the date seed before creating the game. The bot just plays whatever position it's given.
+2 -3
src/lib/atproto.ts
··· 34 34 status?: GameRecord['status']; 35 35 parentGameUri?: string; 36 36 variant?: GameRecord['variant']; 37 - startingFen?: string; 37 + pgn?: string; 38 38 } 39 39 ): Promise<{ uri: string; rkey: string }> { 40 40 const record: GameRecord = { 41 41 $type: 'blue.checkmate.game', 42 - pgn: '', 42 + pgn: options.pgn ?? '', 43 43 createdAt: new Date().toISOString(), 44 44 white: options.white, 45 45 black: options.black, 46 46 status: options.status ?? 'waiting', 47 47 parentGameUri: options.parentGameUri, 48 48 variant: options.variant, 49 - startingFen: options.startingFen, 50 49 }; 51 50 52 51 const response = await agent.com.atproto.repo.createRecord({
+62 -2
src/lib/components/CreateGameForm.svelte
··· 2 2 import { goto } from '$app/navigation'; 3 3 import { auth } from '$lib/stores/auth.svelte'; 4 4 import { createGame, createChallenge, updateChallenge } from '$lib/atproto'; 5 + import { makeInitialPgn } from '$lib/game-logic'; 5 6 import { resolveIdentity } from '$lib/microcosm'; 6 7 import type { GameRecord, ChallengeRecord } from '$lib/types'; 7 8 ··· 10 11 subtitle: string; 11 12 variant?: GameRecord['variant']; 12 13 generateFen?: () => string; 14 + dailyChallenge?: { generateFen: () => string; botHandle: string }; 13 15 } 14 16 15 - let { title, subtitle, variant, generateFen }: Props = $props(); 17 + let { title, subtitle, variant, generateFen, dailyChallenge }: Props = $props(); 18 + 19 + let dailyCreating = $state(false); 16 20 17 21 let opponentHandle = $state(''); 18 22 let colorChoice: 'white' | 'black' | 'random' = $state('white'); ··· 30 34 return { white: opponentDid, black: myDid }; 31 35 } 32 36 37 + async function handleDailyChallenge() { 38 + if (!auth.agent || !auth.did || !dailyChallenge) return; 39 + dailyCreating = true; 40 + error = ''; 41 + 42 + try { 43 + const profile = await resolveIdentity(dailyChallenge.botHandle); 44 + if (!profile) { 45 + error = 'Could not find the bot account'; 46 + dailyCreating = false; 47 + return; 48 + } 49 + 50 + const gameResult = await createGame(auth.agent, { 51 + white: auth.did, 52 + black: profile.did, 53 + status: 'waiting', 54 + variant, 55 + pgn: makeInitialPgn(dailyChallenge.generateFen(), 'Really Bad Chess'), 56 + }); 57 + 58 + const challengeResult = await createChallenge(auth.agent, { 59 + opponent: profile.did, 60 + }); 61 + 62 + await updateChallenge(auth.agent, challengeResult.rkey, { 63 + gameUri: gameResult.uri, 64 + status: 'open', 65 + challengerColor: 'white', 66 + variant, 67 + }); 68 + 69 + goto(`/game/${auth.did}/${gameResult.rkey}`); 70 + } catch (e) { 71 + error = e instanceof Error ? e.message : 'Failed to create daily challenge'; 72 + dailyCreating = false; 73 + } 74 + } 75 + 33 76 async function handleCreateGame() { 34 77 if (!auth.agent || !auth.did) return; 35 78 isCreating = true; ··· 49 92 50 93 const { white, black } = resolveColors(auth.did, opponentDid); 51 94 95 + const fen = generateFen?.(); 52 96 const gameResult = await createGame(auth.agent, { 53 97 white, 54 98 black, 55 99 status: 'waiting', 56 100 variant, 57 - startingFen: generateFen?.(), 101 + pgn: fen ? makeInitialPgn(fen, variant === 'really-bad-chess' ? 'Really Bad Chess' : undefined) : undefined, 58 102 }); 59 103 60 104 const challengerColor: 'white' | 'black' = colorChoice === 'random' ··· 90 134 <p class="text-text-secondary">Sign in to create a game.</p> 91 135 <a href="/" class="text-accent-blue hover:underline">Go to home</a> 92 136 {:else} 137 + {#if dailyChallenge} 138 + <button 139 + onclick={handleDailyChallenge} 140 + disabled={dailyCreating} 141 + class="w-full max-w-sm rounded-lg bg-accent-blue px-6 py-3 font-semibold text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-50" 142 + > 143 + {dailyCreating ? 'Creating...' : "Today's Daily Challenge"} 144 + </button> 145 + 146 + <div class="flex w-full max-w-sm items-center gap-3"> 147 + <div class="h-px flex-1 bg-border"></div> 148 + <span class="text-sm text-text-secondary">or play a friend</span> 149 + <div class="h-px flex-1 bg-border"></div> 150 + </div> 151 + {/if} 152 + 93 153 <form onsubmit={handleCreateGame} class="flex w-full max-w-sm flex-col gap-4"> 94 154 <input 95 155 type="text"
+9 -6
src/lib/game-logic.ts
··· 60 60 return null; 61 61 } 62 62 63 - export function makePgn(chess: Chess, white?: string, black?: string, startingFen?: string): string { 63 + export function makeInitialPgn(fen: string, variant?: string): string { 64 + const chess = new Chess(fen); 65 + chess.header('SetUp', '1'); 66 + chess.header('FEN', fen); 67 + if (variant) chess.header('Variant', variant); 68 + return chess.pgn(); 69 + } 70 + 71 + export function makePgn(chess: Chess, white?: string, black?: string): string { 64 72 const headers: [string, string][] = [ 65 73 ['Event', 'checkmate.blue'], 66 74 ['Site', 'https://checkmate.blue'], ··· 69 77 ]; 70 78 if (white) headers.push(['White', white]); 71 79 if (black) headers.push(['Black', black]); 72 - if (startingFen) { 73 - headers.push(['SetUp', '1']); 74 - headers.push(['FEN', startingFen]); 75 - headers.push(['Variant', 'Really Bad Chess']); 76 - } 77 80 78 81 const result = gameResult(chess); 79 82 headers.push(['Result', result?.result ?? '*']);
+2 -6
src/lib/stores/game.svelte.ts
··· 16 16 let drawOfferedByMe = $state(false); 17 17 let drawOfferedByOpponent = $state(false); 18 18 let storedResult: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string } | null = $state(null); 19 - let startingFen: string | undefined = $state(undefined); 20 19 21 20 export const game = { 22 21 get chess() { return chess; }, ··· 38 37 get pendingPromotion() { return pendingPromotion; }, 39 38 get drawOfferedByMe() { return drawOfferedByMe; }, 40 39 get drawOfferedByOpponent() { return drawOfferedByOpponent; }, 41 - get startingFen() { return startingFen; }, 40 + get startingFen() { return chess.header().FEN as string | undefined; }, 42 41 43 42 init(options: { 44 43 pgn?: string; 45 - startingFen?: string; 46 44 myColor?: PlayerColor; 47 45 whiteHandle?: string; 48 46 blackHandle?: string; ··· 50 48 blackDid?: string; 51 49 status?: GameRecord['status']; 52 50 }) { 53 - chess = options.startingFen ? new Chess(options.startingFen) : new Chess(); 54 - startingFen = options.startingFen; 51 + chess = new Chess(); 55 52 if (options.pgn) { 56 53 chess.loadPgn(options.pgn); 57 54 } ··· 142 139 drawOfferedByMe = false; 143 140 drawOfferedByOpponent = false; 144 141 storedResult = null; 145 - startingFen = undefined; 146 142 }, 147 143 };
+12 -14
src/routes/game/[did]/[rkey]/+page.svelte
··· 14 14 getGame, getGamePublic, updateGame, createGame, 15 15 findGameRecordByParent, findGameRecordByParentPublic, 16 16 } from '$lib/atproto'; 17 - import { makePgn, gameResult } from '$lib/game-logic'; 17 + import { makePgn, makeInitialPgn, gameResult } from '$lib/game-logic'; 18 18 import { Chess } from 'chess.js'; 19 19 import type { GameRecord } from '$lib/types'; 20 20 import { JetstreamConnection } from '$lib/jetstream'; ··· 148 148 149 149 game.init({ 150 150 pgn: record.pgn || undefined, 151 - startingFen: record.startingFen, 152 151 myColor, 153 152 whiteDid: record.white, 154 153 blackDid: record.black, ··· 214 213 if (game.status === 'active' && game.result && auth.agent && myRkey) { 215 214 game.setStatus('completed'); 216 215 await updateGame(auth.agent, myRkey, { 217 - pgn: makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen), 216 + pgn: makePgn(game.chess, game.whiteDid, game.blackDid), 218 217 status: 'completed', 219 218 result: game.result.result, 220 219 resultReason: game.result.reason, ··· 244 243 if (auth.agent && myRkey) { 245 244 game.setStatus('completed'); 246 245 game.setResult('1/2-1/2', 'agreement'); 247 - const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 246 + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); 248 247 await updateGame(auth.agent, myRkey, { 249 248 pgn: finalPgn, 250 249 status: 'completed', ··· 263 262 if (verified && auth.agent && myRkey) { 264 263 game.setStatus('completed'); 265 264 game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); 266 - const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 265 + const finalPgn = makePgn(game.chess, game.whiteDid, game.blackDid); 267 266 await updateGame(auth.agent, myRkey, { 268 267 pgn: finalPgn, 269 268 status: 'completed', ··· 361 360 game.setResult('1/2-1/2', 'agreement'); 362 361 game.setStatus('completed'); 363 362 if (auth.agent && myRkey) { 364 - const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 363 + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); 365 364 await updateGame(auth.agent, myRkey, { 366 365 pgn: finalPgn, 367 366 status: 'completed', ··· 376 375 if (verified && auth.agent && myRkey) { 377 376 game.setStatus('completed'); 378 377 game.setResult(verified.result as '1-0' | '0-1' | '1/2-1/2', verified.resultReason); 379 - const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 378 + const finalPgn = pgn || makePgn(game.chess, game.whiteDid, game.blackDid); 380 379 await updateGame(auth.agent, myRkey, { 381 380 pgn: finalPgn, 382 381 status: 'completed', ··· 453 452 status: 'active', 454 453 parentGameUri: parentUri, 455 454 variant: record.variant, 456 - startingFen: record.startingFen, 455 + pgn: record.pgn || undefined, 457 456 }); 458 457 myRkey = result.rkey; 459 458 game.setStatus('active'); ··· 492 491 async function writeMove() { 493 492 if (!auth.agent || !auth.did || !myRkey) return; 494 493 495 - const pgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 494 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 496 495 const result = gameResult(game.chess); 497 496 try { 498 497 await updateGame(auth.agent, myRkey, { ··· 516 515 moveError = 'Move failed to save. Your last move has been undone.'; 517 516 game.init({ 518 517 pgn: lastPersistedPgn || undefined, 519 - startingFen: game.startingFen, 520 518 myColor: game.myColor, 521 519 whiteDid: game.whiteDid, 522 520 blackDid: game.blackDid, ··· 551 549 game.setStatus('completed'); 552 550 game.setResult('1/2-1/2', 'agreement'); 553 551 game.clearDrawOffers(); 554 - const pgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 552 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 555 553 await updateGame(auth.agent, myRkey, { 556 554 pgn, 557 555 status: 'completed', ··· 582 580 black: auth.did, 583 581 status: 'waiting', 584 582 variant: isVariant ? 'really-bad-chess' : undefined, 585 - startingFen: isVariant ? generateRandomFen() : undefined, 583 + pgn: isVariant ? makeInitialPgn(generateRandomFen(), 'Really Bad Chess') : undefined, 586 584 }); 587 585 goto(`/game/${auth.did}/${result.rkey}`); 588 586 } catch (e) { ··· 592 590 } 593 591 594 592 function downloadPgn() { 595 - const pgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 593 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 596 594 const blob = new Blob([pgn], { type: 'application/x-chess-pgn' }); 597 595 const url = URL.createObjectURL(blob); 598 596 const a = document.createElement('a'); ··· 605 603 async function openLichessAnalysis() { 606 604 analyzingOnLichess = true; 607 605 try { 608 - const pgn = makePgn(game.chess, game.whiteDid, game.blackDid, game.startingFen); 606 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 609 607 const response = await fetch('https://lichess.org/api/import', { 610 608 method: 'POST', 611 609 headers: {
+6 -1
src/routes/play/really-bad-chess/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { page } from '$app/stores'; 2 3 import CreateGameForm from '$lib/components/CreateGameForm.svelte'; 3 - import { generateRandomFen } from '$lib/really-bad-chess'; 4 + import { generateRandomFen, generateDailyFen } from '$lib/really-bad-chess'; 5 + 6 + const DEFAULT_BOT = 'easy.checkmate.blue'; 7 + const botHandle = $derived(new URL($page.url).searchParams.get('bot') ?? DEFAULT_BOT); 4 8 </script> 5 9 6 10 <svelte:head> ··· 12 16 subtitle="Randomized pieces, standard rules" 13 17 variant="really-bad-chess" 14 18 generateFen={generateRandomFen} 19 + dailyChallenge={{ generateFen: generateDailyFen, botHandle }} 15 20 />