From 07b7ab0cd7996f602c47d1b200b1e4bbc8f22093 Mon Sep 17 00:00:00 2001 From: Scott Hadfield Date: Wed, 15 Apr 2026 17:07:16 -0700 Subject: [PATCH] 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. --- docs/really-bad-chess.md | 147 ++++++++++++++++++ src/lib/atproto.ts | 5 +- src/lib/components/CreateGameForm.svelte | 64 +++++++- src/lib/game-logic.ts | 15 +- src/lib/stores/game.svelte.ts | 8 +- src/routes/game/[did]/[rkey]/+page.svelte | 26 ++-- src/routes/play/really-bad-chess/+page.svelte | 7 +- 7 files changed, 240 insertions(+), 32 deletions(-) create mode 100644 docs/really-bad-chess.md diff --git a/docs/really-bad-chess.md b/docs/really-bad-chess.md new file mode 100644 index 0000000..f6b1895 --- /dev/null +++ b/docs/really-bad-chess.md @@ -0,0 +1,147 @@ +# Really Bad Chess + +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. + +Inspired by [Zach Gage's Really Bad Chess](https://apps.apple.com/us/app/really-bad-chess/id1109751921). + +## Rules + +### Pieces and movement + +Standard chess rules apply with one exception: **no castling**. The FEN always has `-` for castling rights since pieces are not in standard positions. + +Everything else works normally: check, checkmate, stalemate, pawn promotion, en passant, threefold repetition, fifty-move rule, insufficient material. + +### Material values + +| Piece | Value | +|--------|-------| +| Pawn | 1 | +| Knight | 3 | +| Bishop | 3 | +| Rook | 5 | +| Queen | 9 | +| King | 0 (mandatory, not counted) | + +For reference, standard chess material is 39 per side (Q=9, 2R=10, 2B=6, 2N=6, 8P=8). + +### Players + +In human-vs-bot games, the human is always white. White always has equal or greater material than black. + +In human-vs-human games, both sides get the same material target. + +--- + +## Board Generation + +Each side has exactly 16 pieces: 1 king + 15 generated pieces. + +### Point budget + +**White:** +1. Pick a target value: random integer in [45, 68]. +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]). + +**Black (human-vs-bot):** +1. Pick a disadvantage: random float in [0%, 13%]. +2. Black's target = floor(white's actual total * (1 - disadvantage)). +3. Fill 15 piece slots with the same +/- 5 variance. + +**Black (human-vs-human):** +Same target as white, 0% disadvantage. + +### Piece selection + +Per side (15 pieces, excluding king): + +1. White is guaranteed at least 1 queen. Black is not. +2. Each side gets 1-5 pawns (random cap chosen per side). +3. Fill all 15 slots by picking random piece types, tracking the remaining budget. +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). +5. If the budget is overshot or can't be met, the remaining slots are filled with pawns. + +### Placement + +1. Kings on their standard squares: white king on e1, black king on e8. +2. Pawns ONLY on rank 2 (white) / rank 7 (black). Pawns cannot be placed on ranks 1 or 8. +3. Back rank (7 open slots after king): queens placed first, then other non-pawn pieces (shuffled). +4. Second rank (8 slots): overflow non-pawn pieces first, then pawns fill remaining slots. + +### FEN format + +The generated position is stored as a standard FEN string: + +``` + w - - 0 1 +``` + +- Active color: `w` (white moves first) +- Castling rights: `-` (no castling) +- En passant: `-` +- Halfmove clock: `0` +- Fullmove number: `1` + +Example: `qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1` + +--- + +## Daily Challenge + +A deterministic board is generated from the date so every player gets the same position on the same day. + +**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`). + +**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. + +Daily challenge games use the human-vs-bot material rules (white advantage of 0-13%). + +--- + +## AT Protocol Records + +### Game record (`blue.checkmate.game`) + +Two fields distinguish a Really Bad Chess game from standard chess: + +| Field | Type | Description | +|-------|------|-------------| +| `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | +| `startingFen` | string (max 200 chars) | The generated FEN string. Omitted for standard chess. | + +Both fields are optional for backward compatibility. A game record without `variant` or `startingFen` is a standard chess game. + +### Challenge record (`blue.checkmate.challenge`) + +| Field | Type | Description | +|-------|------|-------------| +| `variant` | string | `"really-bad-chess"`. Omitted for standard chess. | + +The challenge carries the variant so the opponent knows what they're accepting. The `startingFen` lives only on the game record. + +### PGN headers + +When a game has a custom starting position, the PGN includes standard headers for non-standard start positions: + +``` +[SetUp "1"] +[FEN "qnQbKQnr/BNNBRPPP/8/8/8/8/nnbrqppp/rnqbkbnr w - - 0 1"] +[Variant "Really Bad Chess"] +``` + +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). + +--- + +## Bot Responsibilities + +When the bot receives a game with `variant: "really-bad-chess"`: + +1. **Read the `startingFen`** from the game record and initialize the chess engine from that position. +2. **Play normally** from the custom FEN. Stockfish (or any UCI engine) accepts arbitrary FEN positions via the `position fen moves ` command. +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. +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. + +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. + +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. diff --git a/src/lib/atproto.ts b/src/lib/atproto.ts index 893f340..355a157 100644 --- a/src/lib/atproto.ts +++ b/src/lib/atproto.ts @@ -34,19 +34,18 @@ export async function createGame( status?: GameRecord['status']; parentGameUri?: string; variant?: GameRecord['variant']; - startingFen?: string; + pgn?: string; } ): Promise<{ uri: string; rkey: string }> { const record: GameRecord = { $type: 'blue.checkmate.game', - pgn: '', + pgn: options.pgn ?? '', createdAt: new Date().toISOString(), white: options.white, black: options.black, status: options.status ?? 'waiting', parentGameUri: options.parentGameUri, variant: options.variant, - startingFen: options.startingFen, }; const response = await agent.com.atproto.repo.createRecord({ diff --git a/src/lib/components/CreateGameForm.svelte b/src/lib/components/CreateGameForm.svelte index 08786a9..d86e680 100644 --- a/src/lib/components/CreateGameForm.svelte +++ b/src/lib/components/CreateGameForm.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { auth } from '$lib/stores/auth.svelte'; import { createGame, createChallenge, updateChallenge } from '$lib/atproto'; + import { makeInitialPgn } from '$lib/game-logic'; import { resolveIdentity } from '$lib/microcosm'; import type { GameRecord, ChallengeRecord } from '$lib/types'; @@ -10,9 +11,12 @@ subtitle: string; variant?: GameRecord['variant']; generateFen?: () => string; + dailyChallenge?: { generateFen: () => string; botHandle: string }; } - let { title, subtitle, variant, generateFen }: Props = $props(); + let { title, subtitle, variant, generateFen, dailyChallenge }: Props = $props(); + + let dailyCreating = $state(false); let opponentHandle = $state(''); let colorChoice: 'white' | 'black' | 'random' = $state('white'); @@ -30,6 +34,45 @@ return { white: opponentDid, black: myDid }; } + async function handleDailyChallenge() { + if (!auth.agent || !auth.did || !dailyChallenge) return; + dailyCreating = true; + error = ''; + + try { + const profile = await resolveIdentity(dailyChallenge.botHandle); + if (!profile) { + error = 'Could not find the bot account'; + dailyCreating = false; + return; + } + + const gameResult = await createGame(auth.agent, { + white: auth.did, + black: profile.did, + status: 'waiting', + variant, + pgn: makeInitialPgn(dailyChallenge.generateFen(), 'Really Bad Chess'), + }); + + const challengeResult = await createChallenge(auth.agent, { + opponent: profile.did, + }); + + await updateChallenge(auth.agent, challengeResult.rkey, { + gameUri: gameResult.uri, + status: 'open', + challengerColor: 'white', + variant, + }); + + goto(`/game/${auth.did}/${gameResult.rkey}`); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create daily challenge'; + dailyCreating = false; + } + } + async function handleCreateGame() { if (!auth.agent || !auth.did) return; isCreating = true; @@ -49,12 +92,13 @@ const { white, black } = resolveColors(auth.did, opponentDid); + const fen = generateFen?.(); const gameResult = await createGame(auth.agent, { white, black, status: 'waiting', variant, - startingFen: generateFen?.(), + pgn: fen ? makeInitialPgn(fen, variant === 'really-bad-chess' ? 'Really Bad Chess' : undefined) : undefined, }); const challengerColor: 'white' | 'black' = colorChoice === 'random' @@ -90,6 +134,22 @@

Sign in to create a game.

Go to home {:else} + {#if dailyChallenge} + + +
+
+ or play a friend +
+
+ {/if} +
+ import { page } from '$app/stores'; import CreateGameForm from '$lib/components/CreateGameForm.svelte'; - import { generateRandomFen } from '$lib/really-bad-chess'; + import { generateRandomFen, generateDailyFen } from '$lib/really-bad-chess'; + + const DEFAULT_BOT = 'easy.checkmate.blue'; + const botHandle = $derived(new URL($page.url).searchParams.get('bot') ?? DEFAULT_BOT); @@ -12,4 +16,5 @@ subtitle="Randomized pieces, standard rules" variant="really-bad-chess" generateFen={generateRandomFen} + dailyChallenge={{ generateFen: generateDailyFen, botHandle }} /> -- 2.51.2