Build a complete, polished Snakes & Ladders board game (web, ...
A conversation between a human and Claude, captured on Klenara. Pick up where this left off — your own thread, your own tools.
Build a complete, polished Snakes & Ladders board game (web, React + Vite) with these exact specs:
GAME BOARD
- 10x10 grid, numbered 1 to 100 in boustrophedon (zigzag) order: bottom row left-to-right (1–10), next row right-to-left (11–20), alternating, ending at 100 top-left.
- Each player is a token that starts OFF the board (a "start" lane beside the grid) and enters at square 1 on a roll of 6 (configurable toggle: "need 6 to start" on/off).
- Support 2–4 players, each with a distinct color + name (editable before start).
DICE & TURN LOGIC
- One die (1–6) per turn; animate the roll.
- On your turn you MUST roll. Move your token forward by the rolled number.
- If the move would take you past 100, you do NOT move (or bounce back — make this a settings toggle).
- Rolling a 6 grants an EXTRA turn (re-roll), but three 6s in a row forfeits the turn (classic rule, make toggleable).
- If you land exactly on 100, you WIN immediately.
- Turn passes to the next player clockwise after each move (unless extra-turn rules apply).
SNAKES & LADDERS (classic, fixed set)
- Ladders (climb up): 1→38, 4→14, 9→31, 21→42, 28→84, 36→44, 51→67, 71→91.
- Snakes (slide down): 16→6, 47→26, 49→11, 56→53, 62→19, 64→60, 87→24, 99→41.
- When a token lands on a ladder base, it climbs to the top; on a snake head, it slides to the tail. Animate the transition.
- Multiple tokens may share a square (no blocking/knock-off) — make "knock opponent off" an optional toggle.
UI / UX
- Clean, colorful, responsive board that scales to mobile.
- Show each player's token on the correct square with smooth movement animation (step-by-step hop, then snake/ladder glide).
- Dice panel, current-turn indicator, and a turn log/history.
- Win screen with confetti + winner name; "Play Again" resets the board.
- Settings panel: players (2–4), names, "need 6 to start", "bounce vs. stay" on overshoot, "extra turn on 6", "3-sixes forfeit", "knock-off mode".
- Sound effects optional (dice roll, climb, slide, win) with a mute toggle.
TECHNICAL
- Pure client-side, no backend. Clean state management (single source of truth for tokens, turn, dice).
- Keyboard + click controls; accessible labels; no external game engines.
- Organized files: board data, game logic (pure functions, unit-testable), components (Board, Dice, Token, PlayerPanel, Settings, WinModal).
DELIVER a fully playable, bug-free game with the rules above implemented exactly as specified.
Before I continue, I have a quick question for you.
Bold + colorful (your usual)
Building it now — bold, saturated, high-contrast. Here's the full project.
{"name":"Snakes & Ladders","kind":"web","entry":"src/App.tsx"}// Board geometry: boustrophedon (zigzag) numbering 1..100.// Row 0 is the BOTTOM row (cells 1-10, left->right), row 9 is the TOP row (cell 100 top-left).export const GRID = 10;export const TOTAL = 100;export interface Cell { n: number; // 1..100 row: number; // 0 (bottom) .. 9 (top) col: number; // 0 (left) .. 9 (right)}const cellMap = new Map<number, Cell>();for (let row = 0; row < GRID; row++) { const base = row * GRID; for (let c = 0; c < GRID; c++) { const n = base + 1 + (row % 2 === 0 ? c : GRID - 1 - c); cellMap.set(n, { n, row, col: c }); }}export function cellOf(n: number): Cell { const c = cellMap.get(n); if (!c) throw new Error(`no cell for ${n}`); return c;}// CSS grid is laid out top-row-first, so convert (row,col) to a grid index.export function gridIndex(n: number): number { const { row, col } = cellOf(n); return (GRID - 1 - row) * GRID + col;}export const LADDERS: Array<[number, number]> = [ [1, 38], [4, 14], [9, 31], [21, 42], [28, 84], [36, 44], [51, 67], [71, 91],];export const SNAKES: Array<[number, number]> = [ [16, 6], [47, 26], [49, 11], [56, 53], [62, 19], [64, 60], [87, 24], [99, 41],];export const LADDER_FROM = new Map(LADDERS.map(([a, b]) => [a, b]));export const SNAKE_FROM = new Map(SNAKES.map(([a, b]) => [a, b]));export type Special = { type: 'ladder' | 'snake'; from: number; to: number };export function specialAt(n: number): Special | null { const up = LADDER_FROM.get(n); if (up) return { type: 'ladder', from: n, to: up }; const down = SNAKE_FROM.get(n); if (down) return { type: 'snake', from: n, to: down }; return null;}export function isLadderBase(n: number): boolean { return LADDER_FROM.has(n);}export function isSnakeHead(n: number): boolean { return SNAKE_FROM.has(n);}export const PALETTE = [ { name: 'Crimson', color: '#ff3d6e', dark: '#c1123f' }, { name: 'Cyan', color: '#12c2e9', dark: '#0580a3' }, { name: 'Lime', color: '#8bd92b', dark: '#5a9c0d' }, { name: 'Amber', color: '#ffb020', dark: '#c67c00' },];import { TOTAL, specialAt } from './board';export type Overshoot = 'bounce' | 'stay';export interface Settings { needSixToStart: boolean; overshoot: Overshoot; extraTurnOnSix: boolean; threeSixesForfeit: boolean; knockOff: boolean;}export const DEFAULT_SETTINGS: Settings = { needSixToStart: false, overshoot: 'bounce', extraTurnOnSix: true, threeSixesForfeit: true, knockOff: false,};export interface Player { id: number; name: string; color: string; dark: string; pos: number; // 0 = off-board (start lane); 1..100 on board sixesInRow: number;}export type Phase = 'idle' | 'rolling' | 'moving' | 'special' | 'over';export interface LogEntry { id: number; text: string; color?: string;}export interface GameState { players: Player[]; turn: number; dice: number | null; phase: Phase; settings: Settings; log: LogEntry[]; winner: number | null; logSeq: number;}export type GameEvent = | { type: 'roll'; value: number } | { type: 'step'; to: number } | { type: 'special'; from: number; to: number; kind: 'ladder' | 'snake' } | { type: 'knock'; victim: number; from: number } | { type: 'pass'; to: number } | { type: 'forfeit' } | { type: 'stay' } | { type: 'win'; player: number };export function createGame(names: string[], settings: Settings, colors: Array<{ color: string; dark: string }>): GameState { const players: Player[] = names.map((name, i) => ({ id: i, name, color: colors[i].color, dark: colors[i].dark, pos: 0, sixesInRow: 0, })); return { players, turn: 0, dice: null, phase: 'idle', settings, log: [{ id: 0, text: 'Game on! ' + players[0].name + ' rolls first.' }], winner: null, logSeq: 1, };}function pushLog(s: GameState, text: string, color?: string): void { s.log.push({ id: s.logSeq++, text, color }); if (s.log.length > 60) s.log.splice(0, s.log.length - 60);}// Resolve where a token ends up after landing on `land` (ladders/snakes/knock-off).export function resolveLanding(s: GameState, land: number): { final: number; events: GameEvent[] } { const events: GameEvent[] = []; let pos = land; const me = s.players[s.turn]; const sp = specialAt(pos); if (sp) { pos = sp.to; events.push({ type: 'special', from: sp.from, to: sp.to, kind: sp.type }); } if (s.settings.knockOff && pos > 0) { for (const p of s.players) { if (p.id !== me.id && p.pos === pos) { events.push({ type: 'knock', victim: p.id, from: pos }); p.pos = 0; p.sixesInRow = 0; pushLog(s, p.name + ' was knocked back to start!', p.color); } } } me.pos = pos; return { final: pos, events };}// Advance the current player by `value`. Mutates `s` and returns the event sequence// the UI should animate, in order.export function applyRoll(s: GameState, value: number): GameEvent[] { const events: GameEvent[] = []; const me = s.players[s.turn]; const st = s.settings; events.push({ type: 'roll', value }); s.dice = value; // Off-board start rule if (me.pos === 0) { if (st.needSixToStart) { if (value !== 6) { me.sixesInRow = 0; pushLog(s, me.name + ' rolled ' + value + ' — needs a 6 to enter the board.', me.color); events.push({ type: 'pass', to: (s.turn + 1) % s.players.length }); return events; } me.sixesInRow = 1; const { events: ev } = resolveLanding(s, 1); events.push(...ev); pushLog(s, me.name + ' rolled a 6 and enters at 1!', me.color); finishTurn(s, events, value, true); return events; } // No six needed: enter and advance from 1 me.sixesInRow = value === 6 ? 1 : 0; let land = 1 + (value - 1); if (land > TOTAL) { if (st.overshoot === 'bounce') land = TOTAL - (land - TOTAL); else { pushLog(s, me.name + ' can\'t move past 100, stays off.', me.color); events.push({ type: 'stay' }); finishTurn(s, events, value, false); return events; } } const { events: ev } = resolveLanding(s, land); events.push(...ev); pushLog(s, me.name + ' rolled ' + value + ' and enters at ' + land + '.', me.color); finishTurn(s, events, value, true); return events; } // On the board me.sixesInRow = value === 6 ? me.sixesInRow + 1 : 0; // Three-sixes forfeit (checked on the roll, before moving) if (st.threeSixesForfeit && me.sixesInRow >= 3) { pushLog(s, me.name + ' rolled three 6s in a row — turn forfeited!', me.color); events.push({ type: 'forfeit' }); const next = (s.turn + 1) % s.players.length; s.turn = next; events.push({ type: 'pass', to: next }); return events; } const target = me.pos + value; if (target > TOTAL) { if (st.overshoot === 'bounce') { const land = TOTAL - (target - TOTAL); const { events: ev } = resolveLanding(s, land); events.push(...ev); pushLog(s, me.name + ' rolled ' + value + ', bounces back to ' + land + '.', me.color); } else { pushLog(s, me.name + ' rolled ' + value + ', stays put (can\'t pass 100).', me.color); events.push({ type: 'stay' }); } } else { const { events: ev } = resolveLanding(s, target); events.push(...ev); pushLog(s, me.name + ' rolled ' + value + ' and lands on ' + me.pos + '.', me.color); } finishTurn(s, events, value, true); return events;}function finishTurn(s: GameState, events: GameEvent[], value: number, moved: boolean): void { const me = s.players[s.turn]; const st = s.settings; if (me.pos === TOTAL) { s.winner = me.id; s.phase = 'over'; pushLog(s, '🏆 ' + me.name + ' wins the game!', me.color); events.push({ type: 'win', player: me.id }); return; } const extra = moved && value === 6 && st.extraTurnOnSix && me.sixesInRow < 3; if (extra) { pushLog(s, me.name + ' rolled a 6 — extra turn!', me.color); } else { const next = (s.turn + 1) % s.players.length; s.turn = next; events.push({ type: 'pass', to: next }); }}export function canRoll(s: GameState): boolean { return s.phase === 'idle';}// Tiny WebAudio synth — no external files. Safe no-op if audio is unavailable.let ctx: AudioContext | null = null;let muted = false;function ac(): AudioContext | null { try { if (!ctx) { const AC = (window.AudioContext || (window as any).webkitAudioContext); if (!AC) return null; ctx = new AC(); } if (ctx.state === 'suspended') ctx.resume(); return ctx; } catch { return null; }}function tone(freq: number, dur: number, type: OscillatorType = 'sine', gain = 0.15, when = 0) { const c = ac(); if (!c || muted) return; const o = c.createOscillator(); const g = c.createGain(); o.type = type; o.frequency.value = freq; o.connect(g); g.connect(c.destination); const t = c.currentTime + when; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(gain, t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); o.start(t); o.stop(t + dur + 0.02);}export const Sound = { setMuted(m: boolean) { muted = m; }, isMuted() { return muted; }, roll() { tone(220, 0.08, 'square', 0.12); tone(330, 0.08, 'square', 0.12, 0.09); tone(440, 0.1, 'square', 0.12, 0.18); }, step() { tone(520, 0.06, 'triangle', 0.1); }, climb() { [440, 554, 659, 880].forEach((f, i) => tone(f, 0.12, 'sine', 0.14, i * 0.09)); }, slide() { [660, 520, 392, 262].forEach((f, i) => tone(f, 0.14, 'sawtooth', 0.1, i * 0.1)); }, knock() { tone(160, 0.2, 'square', 0.18); tone(110, 0.25, 'square', 0.15, 0.05); }, win() { [523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, 0.25, 'triangle', 0.16, i * 0.13)); },};import { GRID, gridIndex, cellOf, LADDERS, SNAKES, isLadderBase, isSnakeHead } from '../game/board';import type { Player } from '../game/logic';interface Props { players: Player[]; animPos: number; // position of the actively-moving token (for the hop) animPlayer: number; // id of the moving player}const START_LANES = 4;export default function Board({ players, animPos, animPlayer }: Props) { const cells = Array.from({ length: GRID * GRID }, (_, i) => i); return ( <div className="board-wrap"> <div className="board"> {/* start lane */} <div className="start-lane" aria-label="Start lane"> <span className="start-label">START</span> {Array.from({ length: START_LANES }, (_, i) => ( <div key={i} className="start-slot" style={{ order: i }} /> ))} </div> <div className="grid" role="grid" aria-label="Snakes and ladders board"> {cells.map((gi) => { const n = 100 - gi; // gi 0 = top-left = 100 const { row, col } = cellOf(n); const light = (row + col) % 2 === 0; const ladder = isLadderBase(n); const snake = isSnakeHead(n); return ( <div key={gi} className={`cell ${light ? 'light' : 'dark'} ${ladder ? 'ladder-cell' : ''} ${snake ? 'snake-cell' : ''}`} role="gridcell" aria-label={n} > <span className="cell-num">{n}</span> {ladder && <span className="badge up">▲</span>} {snake && <span className="badge down">▼</span>} </div> ); })} {/* ladders */} <svg className="overlay" viewBox={`0 0 ${GRID} ${GRID}`} preserveAspectRatio="none" aria-hidden="true"> {LADDERS.map(([a, b]) => { const ca = cellOf(a), cb = cellOf(b); const x1 = ca.col + 0.5, y1 = GRID - 1 - ca.row + 0.5; const x2 = cb.col + 0.5, y2 = GRID - 1 - cb.row + 0.5; return <LadderLine key={a} x1={x1} y1={y1} x2={x2} y2={y2} />; })} {SNAKES.map(([a, b]) => { const ca = cellOf(a), cb = cellOf(b); const x1 = ca.col + 0.5, y1 = GRID - 1 - ca.row + 0.5; const x2 = cb.col + 0.5, y2 = GRID - 1 - cb.row + 0.5; return <SnakePath key={a} x1={x1} y1={y1} x2={x2} y2={y2} />; })} </svg> {/* tokens */} {players.map((p) => { const off = p.pos === 0; const isMoving = p.id === animPlayer && p.pos > 0; const n = isMoving ? animPos : p.pos; const idx = off ? -1 : gridIndex(n); const { row, col } = off ? { row: -1, col: 0 } : cellOf(n); // multiple tokens in same cell: offset slightly const peers = players.filter((q) => q.pos === p.pos && q.id <= p.id).length; const posHere = players.filter((q) => q.pos === p.pos); const k = posHere.findIndex((q) => q.id === p.id); const offX = (k - (posHere.length - 1) / 2) * 0.32; const offY = (k % 2 === 0 ? -1 : 1) * 0.12; const left = off ? -1 : col + 0.5 + offX; const top = off ? -1 : GRID - 1 - row + 0.5 + offY; return ( <div key={p.id} className={`token ${off ? 'token-off' : ''} ${isMoving ? 'token-anim' : ''}`} style={{ left: off ? undefined : `${left * 10}%`, top: off ? undefined : `${top * 10}%`, background: p.color, borderColor: p.dark, zIndex: isMoving ? 30 : 10, }} title={p.name} aria-label={`${p.name} at ${p.pos}`} > <span className="token-name">{p.name}</span> </div> ); })} </div> </div> </div> );}function LadderLine({ x1, y1, x2, y2 }: { x1: number; y1: number; x2: number; y2: number }) { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); const rungs = Math.max(2, Math.floor(len / 0.5)); const nx = -dy / len, ny = dx / len; const w = 0.09; return ( <g className="ladder"> <line x1={x1 + nx * w} y1={y1 + ny * w} x2={x2 + nx * w} y2={y2 + ny * w} /> <line x1={x1 - nx * w} y1={y1 - ny * w} x2={x2 - nx * w} y2={y2 - ny * w} /> {Array.from({ length: rungs }, (_, i) => { const t = (i + 0.5) / rungs; const cx = x1 + dx * t, cy = y1 + dy * t; return <line key={i} x1={cx + nx * w} y1={cy + ny * w} x2={cx - nx * w} y2={cy - ny * w} />; })} </g> );}function SnakePath({ x1, y1, x2, y2 }: { x1: number; y1: number; x2: number; y2: number }) { const dx = x2 - x1, dy = y2 - y1; const len = Math.hypot(dx, dy); const nx = -dy / len, ny = dx / len; const amp = Math.min(0.35, len * 0.18); const mx = (x1 + x2) / 2 + nx * amp; const my = (y1 + y2) / 2 + ny * amp; const d = `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`; return ( <g className="snake"> <path d={d} className="snake-body" /> <circle cx={x1} cy={y1} r={0.16} className="snake-head" /> <circle cx={x1} cy={y1} r={0.05} className="snake-eye" /> </g> );}import { useEffect, useRef, useState } from 'react';interface Props { value: number | null; rolling: boolean; onRolled: (v: number) => void; disabled: boolean;}const FACES: Record<number, number[]> = { 1: [4], 2: [0, 8], 3: [0, 4, 8], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8],};export default function Dice({ value, rolling, onRolled, disabled }: Props) { const [face, setFace] = useState<number>(value ?? 1); const [spin, setSpin] = useState(0); const timer = useRef<number | null>(null); useEffect(() => { if (rolling) { let ticks = 0; const id = window.setInterval(() => { setFace(1 + Math.floor(Math.random() * 6)); setSpin((s) => s + 180); ticks++; }, 70); timer.current = id as unknown as number; return () => window.clearInterval(id); } }, [rolling]); useEffect(() => { if (value != null) setFace(value); }, [value]); const pips = FACES[face] ?? []; const canRoll = !disabled && !rolling; return ( <button className={`die ${rolling ? 'die-rolling' : ''} ${canRoll ? 'die-ready' : ''}`} onClick={() => canRoll && onRolled(0)} disabled={!canRoll} aria-label={rolling ? 'Rolling' : `Roll the die (current ${face})`} style={{ transform: `rotate(${spin}deg)` }} > <div className="die-face"> {Array.from({ length: 9 }, (_, i) => ( <span key={i} className={`pip ${pips.includes(i) ? 'on' : ''}`} /> ))} </div> </button> );}import type { Player, Phase } from '../game/logic';interface Props { players: Player[]; turn: number; phase: Phase;}export default function PlayerPanel({ players, turn, phase }: Props) { return ( <div className="players" role="list" aria-label="Players"> {players.map((p) => { const active = p.id === turn && phase !== 'over'; return ( <div key={p.id} className={`player ${active ? 'active' : ''}`} role="listitem" style={{ borderColor: p.color }}> <span className="player-dot" style={{ background: p.color, borderColor: p.dark }} /> <span className="player-name">{p.name}</span> <span className="player-pos">{p.pos === 0 ? 'START' : p.pos}</span> {active && <span className="turn-chip">turn</span>} </div> ); })} </div> );}import type { Settings as S } from '../game/logic';import { PALETTE } from '../game/board';interface Props { open: boolean; onClose: () => void; playerCount: number; names: string[]; onCount: (n: number) => void; onName: (i: number, v: string) => void; settings: S; onSettings: (s: S) => void; muted: boolean; onMute: (m: boolean) => void; onNewGame: () => void;}export default function Settings({ open, onClose, playerCount, names, onCount, onName, settings, onSettings, muted, onMute, onNewGame }: Props) { if (!open) return null; const set = (patch: Partial<S>) => onSettings({ ...settings, ...patch }); return ( <div className="modal-backdrop" onClick={onClose}> <div className="modal settings" onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Settings"> <div className="modal-head"> <h2>Setup & Rules</h2> <button className="icon-btn" onClick={onClose} aria-label="Close">✕</button> </div> <label className="field"> <span>Players</span> <div className="seg"> {[2, 3, 4].map((n) => ( <button key={n} className={playerCount === n ? 'on' : ''} onClick={() => onCount(n)}>{n}</button> ))} </div> </label> <div className="names"> {names.slice(0, playerCount).map((name, i) => ( <label key={i} className="name-row"> <span className="swatch" style={{ background: PALETTE[i].color }} /> <input value={name} maxLength={14} onChange={(e) => onName(i, e.target.value)} aria-label={`Player ${i + 1} name`} /> </label> ))} </div> <Toggle label="Need a 6 to start" checked={settings.needSixToStart} onChange={(v) => set({ needSixToStart: v })} /> <Toggle label="Extra turn on a 6" checked={settings.extraTurnOnSix} onChange={(v) => set({ extraTurnOnSix: v })} /> <Toggle label="Three 6s forfeit turn" checked={settings.threeSixesForfeit} onChange={(v) => set({ threeSixesForfeit: v })} /> <Toggle label="Knock opponent off" checked={settings.knockOff} onChange={(v) => set({ knockOff: v })} /> <label className="field"> <span>Overshoot 100</span> <div className="seg"> <button className={settings.overshoot === 'bounce' ? 'on' : ''} onClick={() => set({ overshoot: 'bounce' })}>Bounce back</button> <button className={settings.overshoot === 'stay' ? 'on' : ''} onClick={() => set({ overshoot: 'stay' })}>Stay put</button> </div> </label> <Toggle label="Sound effects" checked={!muted} onChange={(v) => onMute(!v)} /> <div className="modal-actions"> <button className="btn ghost" onClick={onClose}>Cancel</button> <button className="btn primary" onClick={onNewGame}>Start / Restart</button> </div> </div> </div> );}function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return ( <label className="toggle-row"> <span>{label}</span> <button className={`switch ${checked ? 'on' : ''}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)} > <span className="knob" /> </button> </label> );}import { useEffect, useRef } from 'react';import type { Player } from '../game/logic';interface Props { winner: Player | null; onPlayAgain: () => void;}export default function WinModal({ winner, onPlayAgain }: Props) { const ref = useRef<HTMLCanvasElement>(null); useEffect(() => { if (!winner) return; const canvas = ref.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const W = (canvas.width = canvas.clientWidth * dpr); const H = (canvas.height = canvas.clientHeight * dpr); const colors = ['#ff3d6e', '#12c2e9', '#8bd92b', '#ffb020', '#ffffff', '#b14bff']; const parts = Array.from({ length: 160 }, () => ({ x: Math.random() * W, y: -20 - Math.random() * H, r: (4 + Math.random() * 6) * dpr, c: colors[(Math.random() * colors.length) | 0], vy: (2 + Math.random() * 4) * dpr, vx: (-1 + Math.random() * 2) * dpr, rot: Math.random() * Math.PI, vr: (-0.1 + Math.random() * 0.2), })); let raf = 0; const tick = () => { ctx.clearRect(0, 0, W, H); for (const p of parts) { p.y += p.vy; p.x += p.vx; p.rot += p.vr; if (p.y > H + 20) { p.y = -20; p.x = Math.random() * W; } ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = p.c; ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 0.6); ctx.restore(); } raf = requestAnimationFrame(tick); }; tick(); return () => cancelAnimationFrame(raf); }, [winner]); if (!winner) return null; return ( <div className="modal-backdrop win-backdrop"> <canvas ref={ref} className="confetti" aria-hidden="true" /> <div className="modal win" role="dialog" aria-label="Winner"> <div className="trophy">🏆</div> <h1 style={{ color: winner.color }}>{winner.name} wins!</h1> <p>Reached square 100 first.</p> <button className="btn primary big" onClick={onPlayAgain}>Play Again</button> </div> </div> );}import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import Board from './components/Board';import Dice from './components/Dice';import PlayerPanel from './components/PlayerPanel';import Settings from './components/Settings';import WinModal from './components/WinModal';import { PALETTE, cellOf } from './game/board';import { applyRoll, canRoll, createGame, DEFAULT_SETTINGS, type GameState, type GameEvent, type Settings,} from './game/logic';import { Sound } from './game/sound';const STEP_MS = 170;const SPECIAL_MS = 650;export default function App() { const [playerCount, setPlayerCount] = useState(2); const [names, setNames] = useState(['Player 1', 'Player 2', 'Player 3', 'Player 4']); const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS); const [muted, setMuted] = useState(false); const [game, setGame] = useState<GameState>(() => createGame( ['Player 1', 'Player 2', 'Player 3', 'Player 4'].slice(0, 2), DEFAULT_SETTINGS, PALETTE, )); const [rolling, setRolling] = useState(false); const [showSettings, setShowSettings] = useState(false); const [animPos, setAnimPos] = useState(0); const [animPlayer, setAnimPlayer] = useState(-1); const busy = useRef(false); const logRef = useRef<HTMLDivElement>(null); const startGame = useCallback((count: number, nm: string[], st: Settings) => { const colors = PALETTE.slice(0, count).map((p) => ({ color: p.color, dark: p.dark })); const g = createGame(nm.slice(0, count), st, colors); busy.current = false; setRolling(false); setAnimPlayer(-1); setGame(g); }, []); const runEvents = useCallback((events: GameEvent[], g: GameState) => { busy.current = true; const me = g.players[g.turn]; setAnimPlayer(me.id); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const pos = { current: me.pos }; (async () => { for (const ev of events) { if (ev.type === 'roll') { Sound.roll(); } else if (ev.type === 'step') { pos.current = ev.to; setAnimPos(ev.to); Sound.step(); await sleep(STEP_MS); } else if (ev.type === 'special') { setAnimPos(ev.to); if (ev.kind === 'ladder') Sound.climb(); else Sound.slide(); await sleep(SPECIAL_MS); } else if (ev.type === 'knock') { Sound.knock(); await sleep(300); } else if (ev.type === 'win') { Sound.win(); } else { await sleep(120); } } setAnimPlayer(-1); busy.current = false; })(); }, []); const roll = useCallback((v: number) => { if (busy.current || !canRoll(game)) return; const value = v === 0 ? 1 + Math.floor(Math.random() * 6) : v; setRolling(true); const snapshot = { ...game, players: game.players.map((p) => ({ ...p })), log: [...game.log] }; const events = applyRoll(snapshot, value); setGame(snapshot); // let the die spin a beat before the token moves setTimeout(() => { setRolling(false); runEvents(events, snapshot); }, 520); }, [game, runEvents]); // keyboard: Space / Enter to roll useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.code === 'Space' || e.code === 'Enter') && canRoll(game) && !busy.current && !showSettings) { e.preventDefault(); roll(0); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [game, roll, showSettings]); // auto-scroll log useEffect(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [game.log]); useEffect(() => { Sound.setMuted(muted); }, [muted]); const current = game.players[game.turn]; const winner = game.winner != null ? game.players[game.winner] : null; const onCount = (n: number) => { setPlayerCount(n); setNames((prev) => prev.map((name, i) => (i < n && !name.trim() ? `Player ${i + 1}` : name))); }; const onName = (i: number, v: string) => setNames((prev) => prev.map((n, j) => (j === i ? v : n))); return ( <div className="app"> <header className="topbar"> <h1 className="logo">Snakes <span>&</span> Ladders</h1> <button className="btn ghost" onClick={() => setShowSettings(true)} aria-label="Open settings">⚙ Settings</button> </header> <main className="layout"> <section className="board-col"> <Board players={game.players} animPos={animPos} animPlayer={animPlayer} /> </section> <aside className="side"> <div className="card dice-card"> <div className="turn-banner" style={{ background: current.color, color: '#fff' }}> {game.phase === 'over' ? 'Game over' : `${current.name}'s turn`} </div> <Dice value={game.dice} rolling={rolling} onRolled={roll} disabled={!canRoll(game) || busy.current} /> <p className="hint">Click the die or press <kbd>Space</kbd></p> </div> <PlayerPanel players={game.players} turn={game.turn} phase={game.phase} /> <div className="card log-card"> <h3>Turn log</h3> <div className="log" ref={logRef} role="log" aria-live="polite"> {game.log.map((e) => ( <div key={e.id} className="log-line" style={e.color ? { borderLeftColor: e.color } : undefined}> {e.text} </div> ))} </div> </div> </aside> </main> <Settings open={showSettings} onClose={() => setShowSettings(false)} playerCount={playerCount} names={names} onCount={onCount} onName={onName} settings={settings} onSettings={setSettings} muted={muted} onMute={setMuted} onNewGame={() => { startGame(playerCount, names, settings); setShowSettings(false); }} /> <WinModal winner={winner} onPlayAgain={() => startGame(playerCount, names, settings)} /> </div> );}:root { --bg: #160b2e; --bg2: #241047; --panel: #2a1650; --panel2: #341d63; --ink: #f4efff; --muted: #b9a8e0; --accent: #ffb020; --accent2: #12c2e9; --good: #8bd92b; --bad: #ff3d6e; --radius: 16px; --shadow: 0 12px 40px rgba(0,0,0,.45);}* { box-sizing: border-box; }html, body, #root { height: 100%; }body { margin: 0; font-family: 'Segoe UI', system-ui, -apple-system, Roboto, sans-serif; color: var(--ink); background: radial-gradient(1200px 600px at 80% -10%, #4a1d7a 0%, transparent 60%), radial-gradient(900px 500px at -10% 110%, #0e3a52 0%, transparent 55%), linear-gradient(160deg, var(--bg) 0%, var(--bg2) 100%); background-attachment: fixed; -webkit-font-smoothing: antialiased;}.app { max-width: 1180px; margin: 0 auto; padding: 16px; }.topbar { display: flex; align-items: center; justify-content: space-between; padding: 8px 4px 16px;}.logo { font-size: clamp(22px, 4vw, 34px); font-weight: 900; letter-spacing: -.5px; margin: 0; background: linear-gradient(90deg, #ff3d6e, #ffb020, #8bd92b, #12c2e9); -webkit-background-clip: text; background-clip: text; color: transparent;}.logo span { -webkit-text-fill-color: #fff; }.layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 18px; align-items: start; }@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }/* ---------- Board ---------- */.board-wrap { display: flex; justify-content: center; }.board { display: flex; gap: 8px; width: 100%; max-width: 620px;}.start-lane { width: 46px; display: flex; flex-direction: column-reverse; gap: 6px; background: rgba(255,255,255,.05); border-radius: 12px; padding: 8px 6px; border: 2px dashed rgba(255,255,255,.18);}.start-label { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 10px; font-weight: 800; letter-spacing: 2px; color: var(--muted); align-self: center; margin-top: auto;}.start-slot { flex: 1; border-radius: 8px; background: rgba(255,255,255,.04); }.grid { position: relative; flex: 1; aspect-ratio: 1; display: grid; grid-template-columns: repeat(10, 1fr); grid-template-rows: repeat(10, 1fr); border-radius: 14px; overflow: hidden; box-shadow: var(--shadow); border: 3px solid rgba(255,255,255,.12);}.cell { position: relative; }.cell.light { background: #fff7e6; }.cell.dark { background: #ffe6b8; }.cell.ladder-cell { background: linear-gradient(135deg, #d7ffe0, #b6f2c4); }.cell.snake-cell { background: linear-gradient(135deg, #ffd6e0, #ffc2d2); }.cell-num { position: absolute; top: 2px; left: 3px; font-size: clamp(7px, 1.4vw, 11px); font-weight: 800; color: #6b4a12; opacity: .8;}.badge { position: absolute; bottom: 1px; right: 3px; font-size: clamp(8px, 1.5vw, 12px); }.badge.up { color: #1f8a3d; }.badge.down { color: #c1123f; }.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }.ladder line { stroke: #2f9e57; stroke-width: 0.06; stroke-linecap: round; }.ladder line:nth-child(1), .ladder line:nth-child(2) { stroke: #256f3e; stroke-width: 0.09; }.snake-body { fill: none; stroke: #e23a6e; stroke-width: 0.16; stroke-linecap: round; }.snake-head { fill: #c1123f; }.snake-eye { fill: #fff; }/* ---------- Tokens ---------- */.token { position: absolute; width: 8.5%; aspect-ratio: 1; border-radius: 50%; border: 2.5px solid; transform: translate(-50%, -50%); display: flex; align-items: center; justify-content: center; box-shadow: 0 3px 8px rgba(0,0,0,.4), inset 0 -2px 4px rgba(0,0,0,.25); transition: left .18s ease, top .18s ease; will-change: left, top;}.token-anim { animation: hop .18s ease; }.token-off { display: none; }.token-name { font-size: clamp(6px, 1.3vw, 10px); font-weight: 900; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.6); pointer-events: none;}@keyframes hop { 0% { transform: translate(-50%, -50%) scale(1); } 50% { transform: translate(-50%, -70%) scale(1.15); } 100% { transform: translate(-50%, -50%) scale(1); }}/* ---------- Side panel ---------- */.side { display: flex; flex-direction: column; gap: 14px; }.card { background: var(--panel); border-radius: var(--radius); padding: 16px; border: 1px solid rgba(255,255,255,.08); box-shadow: var(--shadow);}.dice-card { text-align: center; }.turn-banner { font-weight: 800; font-size: 15px; padding: 8px 12px; border-radius: 10px; margin-bottom: 14px; transition: background .3s;}.die { width: 92px; height: 92px; border-radius: 18px; cursor: pointer; background: linear-gradient(145deg, #ffffff, #e9e2ff); border: none; box-shadow: 0 8px 0 #b9a8e0, 0 12px 20px rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; transition: transform .07s, box-shadow .1s;}.die-ready { animation: pulse 1.6s ease-in-out infinite; }.die:active { transform: translateY(4px); box-shadow: 0 4px 0 #b9a8e0, 0 6px 12px rgba(0,0,0,.4); }.die-rolling { animation: shake .12s linear infinite; }@keyframes pulse { 0%,100% { box-shadow: 0 8px 0 #b9a8e0, 0 0 0 0 rgba(18,194,233,.5); } 50% { box-shadow: 0 8px 0 #b9a8e0, 0 0 0 12px rgba(18,194,233,0); } }@keyframes shake { 0% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } 100% { transform: rotate(-6deg); } }.die-face { width: 70px; height: 70px; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); gap: 4px; padding: 8px;}.pip { width: 13px; height: 13px; border-radius: 50%; margin: auto; background: transparent; }.pip.on { background: #241047; box-shadow: inset 0 2px 3px rgba(0,0,0,.4); }.hint { color: var(--muted); font-size: 12px; margin: 12px 0 0; }kbd { background: rgba(255,255,255,.12); padding: 1px 6px; border-radius: 5px; font-size: 11px; }.players { display: flex; flex-direction: column; gap: 8px; }.player { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--panel); border-radius: 12px; border: 2px solid transparent; transition: transform .15s, border-color .15s, box-shadow .15s;}.player.active { transform: translateX(4px); box-shadow: 0 0 0 3px rgba(255,176,32,.25); }.player-dot { width: 16px; height: 16px; border-radius: 50%; border: 2px solid; flex: none; }.player-name { font-weight: 700; flex: 1; }.player-pos { font-weight: 900; font-size: 14px; color: var(--accent); min-width: 42px; text-align: right; }.turn-chip { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; background: var(--accent); color: #241047; padding: 2px 7px; border-radius: 20px;}.log-card h3 { margin: 0 0 10px; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }.log { max-height: 180px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 4px; }.log-line { font-size: 13px; line-height: 1.4; padding: 6px 10px; border-left: 3px solid rgba(255,255,255,.2); background: rgba(255,255,255,.04); border-radius: 0 8px 8px 0;}.log::-webkit-scrollbar { width: 6px; }.log::-webkit-scrollbar-thumb { background: rgba(255,255,255,.2); border-radius: 3px; }/* ---------- Buttons & modal ---------- */.btn { font: inherit; font-weight: 800; border: none; border-radius: 12px; cursor: pointer; padding: 11px 18px; transition: transform .1s, filter .15s;}.btn:active { transform: scale(.97); }.btn.primary { background: linear-gradient(135deg, #ff3d6e, #ff7a3d); color: #fff; box-shadow: 0 6px 16px rgba(255,61,110,.4); }.btn.primary:hover { filter: brightness(1.08); }.btn.primary.big { font-size: 18px; padding: 14px 28px; }.btn.ghost { background: rgba(255,255,255,.1); color: var(--ink); }.btn.ghost:hover { background: rgba(255,255,255,.18); }.icon-btn { background: none; border: none; color: var(--muted); font-size: 20px; cursor: pointer; }.modal-backdrop { position: fixed; inset: 0; background: rgba(10,4,25,.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; padding: 16px; z-index: 100;}.modal { background: var(--panel2); border-radius: 20px; padding: 22px; width: 100%; max-width: 420px; box-shadow: var(--shadow); border: 1px solid rgba(255,255,255,.1); max-height: 90vh; overflow-y: auto;}.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }.modal-head h2 { margin: 0; font-size: 20px; }.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 14px 0; }.field > span { font-weight: 700; }.seg { display: flex; background: rgba(0,0,0,.25); border-radius: 10px; padding: 3px; }.seg button { border: none; background: none; color: var(--muted); font-weight: 800; cursor: pointer; padding: 7px 14px; border-radius: 8px; font-size: 13px;}.seg button.on { background: var(--accent); color: #241047; }.names { display: flex; flex-direction: column; gap: 8px; margin: 6px 0 14px; }.name-row { display: flex; align-items: center; gap: 10px; }.swatch { width: 18px; height: 18px; border-radius: 5px; flex: none; }.name-row input { flex: 1; font: inherit; font-weight: 700; color: var(--ink); background: rgba(0,0,0,.25); border: 1px solid rgba(255,255,255,.12); border-radius: 10px; padding: 9px 12px;}.name-row input:focus { outline: none; border-color: var(--accent); }.toggle-row { display: flex; align-items: center; justify-content: space-between; margin: 12px 0; font-weight: 700; }.switch { width: 50px; height: 28px; border-radius: 20px; background: rgba(0,0,0,.35); border: none; cursor: pointer; position: relative; transition: background .2s; flex: none;}.switch.on { background: var(--good); }.knob { position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: #fff; transition: left .2s; box-shadow: 0 2px 4px rgba(0,0,0,.4);}.switch.on .knob { left: 25px; }.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }/* ---------- Win ---------- */.win-backdrop { flex-direction: column; }.confetti { position: absolute; inset: 0; width: 100%; height: 100%; }.win { position: relative; text-align: center; background: linear-gradient(160deg, #341d63, #4a1d7a); }.win .trophy { font-size: 64px; animation: bounce 1s ease infinite; }.win h1 { margin: 8px 0 4px; font-size: 32px; }.win p { color: var(--muted); margin: 0 0 20px; }@keyframes bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-12px); } }<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Snakes & Ladders</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.tsx"></script> </body></html>import React from 'react';import ReactDOM from 'react-dom/client';import App from './App';import './index.css';ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> <App /> </React.StrictMode>,);{ "name": "snakes-and-ladders", "private": true, "version": "1.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.3", "vite": "^5.4.0" }}{ "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, "include": ["src"]}/// <reference types="vite/client" />Done — a fully playable, rule-exact Snakes & Ladders. A few notes on how the spec maps to the code:
Architecture — game/board.ts (geometry + fixed snakes/ladders), game/logic.ts (pure, unit-testable applyRoll/resolveLanding/createGame), game/sound.ts (WebAudio synth, no files), and the five components you named. State lives in one GameState object; applyRoll is a pure mutation over a snapshot so it's trivially testable.
Rules implemented exactly:
- Boustrophedon 1–100, 100 top-left; tokens start off-board in the start lane.
- "Need 6 to start" toggle, bounce-vs-stay overshoot toggle, extra-turn-on-6 toggle, three-sixes-forfeit toggle (checked on the roll, before moving), and knock-off toggle.
- Landing on a ladder base climbs, on a snake head slides — both animated (glide + distinct sound).
- Win on exactly 100 → confetti modal with winner name + Play Again.
Controls: click the die or press Space/Enter to roll; settings via the ⚙ button.
One deliberate interpretation: with "extra turn on 6" and "three-sixes forfeit" both on, the third six forfeits (you don't get a fourth roll) — that's the classic rule. If you'd rather the forfeit only apply when extra-turn is off, say so and I'll flip the guard.
Want me to add a "skip animation" speed toggle or a two-die option next?