Replace all display-name occurrences across web pages, Rust source, docs, and Cargo metadata. Update localStorage token key from sq_token to fs_token. Tagline "Klondike Solitaire" retained as genre descriptor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
43 KiB
Ferrous Solitaire — Architecture Document
Version: 1.3
Language: Rust (Edition 2024)
Engine: Bevy (latest stable)
Last Updated: 2026-05-12
Table of Contents
- Project Overview
- Workspace Structure
- Crate Responsibilities
- Data Flow
- Game Engine Architecture
- Persistence & Sync Architecture
- Sync Server Architecture
- Data Models
- API Reference
- Merge Strategy
- Achievement System
- Progression System
- Audio System
- Asset Pipeline
- Platform Targets
- Build & Development Guide
- Deployment Guide
- Security Model
- Testing Strategy
- Decision Log
1. Project Overview
Ferrous Solitaire is a cross-platform Klondike Solitaire game written in Rust, targeting macOS, Windows, and Linux desktops. It features a full progression system with XP, levels, achievements, daily challenges, and an optional self-hosted sync server so statistics and progress are available across all of a player's devices.
Sync Backend by Platform
| Platform | Primary Sync | Notes |
|---|---|---|
| macOS | Self-hosted server | Full feature set |
| Windows | Self-hosted server | Full feature set |
| Linux | Self-hosted server | Full feature set |
| Android | Self-hosted server | Touch input; safe-area insets via JNI; cargo-apk build |
Design Principles
- Offline first. The local file is always the source of truth. Sync is additive, never destructive.
- One language, one repo. The game client, sync client, shared types, and sync server are all Rust crates in a single Cargo workspace.
- Plugin-based Bevy architecture. Each major feature is a Bevy
Plugin. Systems are small and single-purpose. Cross-system communication uses BevyEvents.
Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enforced by CLAUDE.md §2.1, §2.3, and §3.3 respectively — those are the canonical statements; this file describes the design that motivates them.
2. Workspace Structure
solitaire_quest/
│
├── Cargo.toml # Workspace manifest
├── .env.example # Server environment variable template
├── ARCHITECTURE.md # This document
├── README.md # Player-facing readme
├── README_SERVER.md # Self-hosting guide
├── Dockerfile # Multi-stage server build
├── docker-compose.yml # Server + Caddy reverse proxy
│
├── assets/ # Loaded at runtime via AssetServer (audio is embedded via include_bytes!())
│ ├── cards/
│ │ ├── faces/{RANK}{SUIT}.png # 52 card faces — rendered from hayeah/playing-cards-assets SVGs (MIT)
│ │ └── backs/back_0.png – back_4.png # back_0 = generated default back; back_1–4 are generated patterns
│ ├── backgrounds/bg_0.png – bg_4.png # generated textures
│ ├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
│ └── audio/
│ ├── card_deal.wav
│ ├── card_flip.wav
│ ├── card_place.wav
│ ├── card_invalid.wav
│ ├── win_fanfare.wav
│ └── ambient_loop.wav
│
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
├── solitaire_sync/ # Shared API types — used by client and server
├── solitaire_data/ # Persistence, sync client, settings
├── solitaire_engine/ # Bevy ECS systems, components, plugins
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
└── solitaire_app/ # Main binary entry point
3. Crate Responsibilities
solitaire_core
Dependencies: rand, serde, chrono only.
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with cargo test -p solitaire_core.
Owns:
- All game data models (
Card,Suit,Rank,Pile,GameState) - Move validation logic
- Scoring engine
- Undo stack
- Win / auto-complete detection
- Achievement unlock condition evaluation
- Seeded RNG for reproducible deals
solitaire_sync
Dependencies: serde, serde_json, uuid, chrono only.
Shared API contract types imported by both the game client (solitaire_data) and the server (solitaire_server). Changing a type here is a breaking change on both sides — version carefully.
Owns:
SyncPayload,SyncResponse,ConflictReportChallengeGoal,LeaderboardEntryApiErrorenum- Merge logic (pure functions, no I/O)
solitaire_data
Dependencies: solitaire_core, solitaire_sync, serde_json, dirs, keyring, reqwest, tokio (minimal).
All persistence and sync client code. No Bevy dependency — Bevy systems in solitaire_engine call into this crate via the SyncPlugin.
Owns:
- Local file read/write (atomic via
.tmp→ rename) StatsSnapshot,PlayerProgress,AchievementRecordpersistenceSyncBackendenum and backend selection- Solitaire Server sync client (JWT auth, auto-refresh)
- OS keychain integration (
keyring) SyncProvidertrait — implemented bySolitaireServerClient
solitaire_engine
Dependencies: bevy, kira, solitaire_core, solitaire_data.
All Bevy-specific code. Structured as a collection of Plugins that solitaire_app registers.
Owns:
- Bevy ECS components and resources
- Rendering systems (card sprites, table, backgrounds)
- Drag-and-drop input handling
- Animation systems (slide, flip, win cascade, toast)
- All Bevy UI screens (Home, Stats, Achievements, Settings, Profile)
- Audio playback systems
- Sync status display
- Card, background, and font asset loading via Bevy
AssetServer(audio is the lone exception — embedded viainclude_bytes!()inaudio_plugin.rs)
solitaire_server
Dependencies: solitaire_sync, axum, sqlx, jsonwebtoken, bcrypt, tower-governor, tracing, tokio, dotenvy.
Standalone binary. Can be built and run independently of the game.
Owns:
- HTTP API (see Section 9)
- SQLite database schema and migrations
- Auth (registration, login, JWT issuance and refresh)
- Server-side merge logic (delegates to
solitaire_sync) - Rate limiting
- Daily challenge seed generation
- Leaderboard management
solitaire_wasm
Dependencies: solitaire_core, serde, serde_json, chrono, wasm-bindgen, serde-wasm-bindgen.
WebAssembly bindings for browser-side replay playback. Compiled to cdylib via wasm-pack build; the output lives in solitaire_server/web/pkg/ and is served statically by the server.
Intentionally does not depend on solitaire_data (which pulls in dirs, keyring, reqwest, and other non-WASM crates). Instead it defines a minimal Replay mirror with the same serde shape as solitaire_data::Replay — the JSON wire format is the compatibility contract.
Owns:
ReplayPlayer— WASM-exported state machine; steps through a replay'sVec<ReplayMove>against a liveGameStateStateSnapshot— JS-facing pile snapshot returned by eachstep()callReplayMove/Replaymirrors — same serde shape assolitaire_datav2 equivalents
Because ReplayPlayer uses the same solitaire_core::GameState as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
solitaire_app
Dependencies: bevy, solitaire_engine.
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
4. Data Flow
Game Loop (local, no sync)
User Input
│
▼
Bevy InputSystem
│ fires GameInputEvent
▼
GameLogicSystem (solitaire_engine)
│ calls solitaire_core::GameState::move_cards() → Result
▼
GameStateResource updated
│ fires StateChangedEvent
▼
RenderSystem ScoreSystem AchievementSystem
(update sprites) (update score HUD) (check unlock conditions)
│
│ fires AchievementUnlockedEvent
▼
ToastSystem (Bevy UI popup)
PersistenceSystem (write to disk)
Sync Flow (on launch)
App starts
│
▼
SyncPlugin::on_startup()
│ spawns AsyncComputeTask
▼
solitaire_data::sync_pull() ← dispatches to active SyncProvider
│ SolitaireServerClient
▼
solitaire_sync::merge(local, remote)
│
▼
Write merged result to disk
│ fires SyncCompleteEvent
▼
Bevy main thread reads updated StatsResource
Sync Flow (on exit)
AppExit event
│
▼
SyncPlugin::on_exit()
│ blocking push (acceptable on exit, not on main loop)
▼
active SyncProvider::push(local)
│ POST to server
▼
Done
5. Game Engine Architecture
Bevy Plugins
The "Shortcut" column lists optional keyboard accelerators. Every action in this table must also be reachable from a visible UI control (button, menu item, on-screen affordance) per the UI-first design principle in §1; the shortcut is a power-user convenience, not the sole entry point.
| Plugin | Shortcut | Responsibility |
|---|---|---|
CardPlugin |
— | Card entity spawning, sprite management, drag-and-drop |
TablePlugin |
— | Pile markers, background, layout calculation |
FontPlugin |
— | Loads FiraMono-Medium via AssetServer at startup; exposes FontResource handle |
AnimationPlugin |
— | Slide, flip, win cascade, toast animations |
FeedbackAnimPlugin |
— | Shake, settle, and deal-stagger animations |
AutoCompletePlugin |
Enter | Executes auto-complete when the HUD badge is lit |
AudioPlugin |
— | Sound effect and music playback via kira |
InputPlugin |
— | Keyboard and mouse input routing |
CursorPlugin |
— | Custom cursor sprite during drag |
SelectionPlugin |
— | Keyboard-driven card selection |
GamePlugin |
N | Core game state resource, new-game flow, win/game-over overlays |
HudPlugin |
— | Score, move counter, timer, auto-complete badge, and the top-right action button bar (Undo / Pause / Help / New Game). Each button fires the same request event the corresponding hotkey does. |
StatsPlugin |
S | Stats overlay and persistence |
ProgressPlugin |
— | XP/level system, persistence |
AchievementPlugin |
A | Unlock evaluation, toast events, persistence |
DailyChallengePlugin |
— | Daily challenge resource and completion tracking |
WeeklyGoalsPlugin |
— | Weekly goal progress and completion events |
ChallengePlugin |
— | Challenge mode progression (seeded hard deals) |
TimeAttackPlugin |
— | 10-minute time-attack mode timer |
HomePlugin |
M | Main-menu overlay with keyboard shortcut reference |
ProfilePlugin |
P | Player profile overlay: level, XP, achievements, sync status |
SettingsPlugin |
O | Settings panel: audio, draw mode, theme, sync, cosmetics |
ThemePlugin |
— | Owns ActiveTheme resource; registers the CardTheme SVG asset loader; rasterises themes once per (theme, target size) at load time and caches the resulting Image; handles the embedded default theme and user themes from user_theme_dir() |
SyncSetupPlugin |
— | Sync setup modal (URL / username / password fields, "Log In" / "Register" buttons); account deletion confirm modal; re-auth trigger when SyncError::Auth is returned by a pull |
LeaderboardPlugin |
L | Leaderboard overlay |
HelpPlugin |
H | Help / controls overlay |
PausePlugin |
Esc | Pause and resume |
OnboardingPlugin |
— | First-run welcome screen |
SyncPlugin |
— | Async sync lifecycle (pull on start, push on exit, status display) |
WinSummaryPlugin |
— | Win cascade overlay and screen-shake effect |
Key Bevy Resources
// Current game state — single source of truth for the active game
struct GameStateResource(GameState);
// Sync status shown in Settings screen
enum SyncStatus { Idle, Syncing, LastSynced(DateTime<Utc>), Error(String) }
struct SyncStatusResource(SyncStatus);
// Currently active drag operation
struct DragState {
cards: Vec<u32>, // card ids being dragged
origin_pile: PileType,
cursor_offset: Vec2,
origin_z: f32,
}
// Loaded user data
struct StatsResource(StatsSnapshot);
struct ProgressResource(PlayerProgress);
struct AchievementsResource(Vec<AchievementRecord>);
struct SettingsResource(Settings);
// Pre-loaded card face and back PNG handles
struct CardImageSet {
faces: [[Handle<Image>; 13]; 4], // [suit][rank]: Clubs=0..Spades=3, Ace=0..King=12
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
}
// Project-wide font handle (FiraMono-Medium loaded via AssetServer at startup)
struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
}
// OS-reserved edge insets (physical px); zero on desktop
struct SafeAreaInsets { top: f32, bottom: f32, left: f32, right: f32 }
// Whether the HUD band is visible (auto-hide chrome feature)
enum HudVisibility { Visible, Hidden }
Key Bevy Events
// Input → Logic
struct MoveRequestEvent { from: PileType, to: PileType, count: usize }
struct DrawRequestEvent;
struct UndoRequestEvent;
struct NewGameRequestEvent { seed: Option<u64> }
// Logic → Rendering/UI
struct StateChangedEvent;
struct CardFlippedEvent(u32);
struct GameWonEvent { score: i32, time_seconds: u64 }
struct AchievementUnlockedEvent(AchievementRecord);
struct SyncCompleteEvent(Result<SyncResponse, String>);
Layout System
Card and pile positions are calculated from window dimensions on startup and on every WindowResized event.
Window width → card_width = window_width / 9.0 (7 columns + 2 margins)
Window height → card_height = card_width * 1.4 (standard card aspect ratio)
Pile spacing → h_gap = (window_width - 7 * card_width) / 8.0
Minimum window: 800×600. At this size cards are small but usable.
6. Persistence & Sync Architecture
Local Storage
All files stored under dirs::data_dir() / "solitaire_quest"/:
~/.local/share/solitaire_quest/ (Linux)
~/Library/Application Support/solitaire_quest/ (macOS)
%APPDATA%\solitaire_quest\ (Windows)
│
├── stats.json # StatsSnapshot
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
├── achievements.json # Vec<AchievementRecord>
├── settings.json # Settings (draw mode, audio, theme, sync backend)
└── game_state.json # In-progress game (saved on pause/exit, deleted on win/loss)
Atomic writes: all saves go to filename.json.tmp first, then rename() — ensuring a crash mid-write never corrupts saved data.
SyncProvider Trait
All sync backends implement a single trait in solitaire_data. The SyncPlugin holds a Box<dyn SyncProvider + Send + Sync> and is backend-agnostic.
#[async_trait]
pub trait SyncProvider: Send + Sync {
// Required — must be implemented by every backend:
async fn pull(&self) -> Result<SyncPayload, SyncError>;
async fn push(&self, payload: &SyncPayload) -> Result<SyncResponse, SyncError>;
fn backend_name(&self) -> &'static str;
fn is_authenticated(&self) -> bool;
// Optional — all have default no-op / empty implementations:
async fn mirror_achievement(&self, _id: &str) -> Result<(), SyncError>;
async fn fetch_leaderboard(&self) -> Result<Vec<LeaderboardEntry>, SyncError>;
async fn fetch_daily_challenge(&self) -> Result<Option<ChallengeGoal>, SyncError>;
async fn opt_in_leaderboard(&self, _display_name: &str) -> Result<(), SyncError>;
async fn opt_out_leaderboard(&self) -> Result<(), SyncError>;
async fn delete_account(&self) -> Result<(), SyncError>;
// Returns the shareable web URL on success; defaults to Err(UnsupportedPlatform)
// so LocalOnlyProvider silently no-ops the push-on-win path.
async fn push_replay(&self, _replay: &Replay) -> Result<String, SyncError>;
}
Implementations:
| Struct | Backend | Platforms |
|---|---|---|
LocalOnlyProvider |
No-op (default) | All |
SolitaireServerClient |
Self-hosted server | All |
Sync always runs on bevy::tasks::AsyncComputeTaskPool — the game thread is never blocked.
Sync Backends (Settings enum)
pub enum SyncBackend {
Local,
SolitaireServer {
url: String,
username: String,
// JWT access + refresh tokens stored in OS keychain
// key: "solitaire_quest_server_{username}"
},
}
Solitaire Server Sync
On launch: GET /api/sync/pull with Authorization: Bearer {access_token}
On exit: POST /api/sync/push with payload
On 401: automatically attempt POST /api/auth/refresh, retry once, then surface error to user.
Credentials stored in OS keychain via keyring — never in plaintext on disk.
7. Sync Server Architecture
Stack
| Component | Crate |
|---|---|
| HTTP framework | axum |
| Database | sqlx with SQLite |
| Auth | jsonwebtoken + bcrypt |
| Rate limiting | tower-governor |
| Logging | tracing + tracing-subscriber |
| Config | dotenvy |
| Shared types | solitaire_sync (workspace crate) |
Database Schema
-- migrations/001_initial.sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- UUID v4
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL, -- bcrypt, cost 12
created_at TEXT NOT NULL, -- ISO 8601
leaderboard_opt_in INTEGER DEFAULT 0
);
CREATE TABLE sync_state (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
stats_json TEXT NOT NULL,
achievements_json TEXT NOT NULL,
progress_json TEXT NOT NULL,
last_modified TEXT NOT NULL
);
CREATE TABLE daily_challenges (
date TEXT PRIMARY KEY, -- "YYYY-MM-DD"
seed INTEGER NOT NULL,
goal_json TEXT NOT NULL
);
CREATE TABLE leaderboard (
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
best_time_secs INTEGER,
best_score INTEGER,
recorded_at TEXT NOT NULL,
PRIMARY KEY (user_id)
);
-- migrations/002_replays.sql
CREATE TABLE IF NOT EXISTS replays (
id TEXT PRIMARY KEY, -- UUID v4 minted server-side
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seed INTEGER NOT NULL,
draw_mode TEXT NOT NULL, -- "DrawOne" | "DrawThree"
mode TEXT NOT NULL, -- "Classic" | "Zen" | "Challenge" | "TimeAttack"
time_seconds INTEGER NOT NULL,
final_score INTEGER NOT NULL,
recorded_at TEXT NOT NULL, -- replay-side date (YYYY-MM-DD)
received_at TEXT NOT NULL, -- server insert timestamp (ISO 8601)
replay_json TEXT NOT NULL -- full Replay serialisation
);
CREATE INDEX IF NOT EXISTS replays_received_at_idx ON replays(received_at DESC);
CREATE INDEX IF NOT EXISTS replays_user_id_idx ON replays(user_id);
Request Lifecycle
Client Request
│
▼
tower-governor (rate limiter — 10 req/min on /api/auth/*)
│
▼
axum Router
│
├─ /api/auth/* → AuthHandler (no JWT required)
│
└─ /api/* → JwtMiddleware → Handler
│
├─ Validate JWT signature + expiry
├─ Reject payload > 1MB
└─ Extract user_id for handler
Daily Challenge Generation
If no row exists in daily_challenges for today's date, the server generates one on first request:
let seed = hash_date_to_u64("2026-04-19"); // deterministic, same for all players
let goal = generate_goal_from_seed(seed); // seeded RNG picks goal type + params
This ensures all players worldwide get the same challenge for a given date, regardless of which server instance handles the request.
8. Data Models
Core Game Models (solitaire_core)
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
pub struct Card {
pub id: u32,
pub suit: Suit,
pub rank: Rank,
pub face_up: bool,
}
pub enum PileType {
Stock,
Waste,
Foundation(Suit),
Tableau(usize), // 0–6
}
pub enum DrawMode { DrawOne, DrawThree }
/// Active game mode. Classic is the default; others unlock at level 5.
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
pub enum MoveError {
InvalidSource,
InvalidDestination,
EmptySource,
RuleViolation(String),
UndoStackEmpty,
GameAlreadyWon,
}
pub struct GameState {
pub piles: HashMap<PileType, Vec<Card>>,
pub draw_mode: DrawMode,
pub mode: GameMode,
pub score: i32,
pub move_count: u32,
pub undo_count: u32, // number of undos used in this game
pub recycle_count: u32, // number of stock recycles
pub elapsed_seconds: u64,
pub seed: u64,
pub is_won: bool,
pub is_auto_completable: bool,
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
}
Persistence Models (solitaire_data)
pub struct StatsSnapshot {
pub games_played: u32,
pub games_won: u32,
pub games_lost: u32,
pub win_streak_current: u32,
pub win_streak_best: u32,
pub avg_time_seconds: u64,
pub fastest_win_seconds: u64,
pub lifetime_score: u64,
pub best_single_score: u32,
pub draw_one_wins: u32,
pub draw_three_wins: u32,
pub last_modified: DateTime<Utc>,
}
pub struct PlayerProgress {
pub total_xp: u64,
pub level: u32,
pub daily_challenge_last_completed: Option<NaiveDate>,
pub daily_challenge_streak: u32,
pub weekly_goal_progress: HashMap<String, u32>,
pub unlocked_card_backs: Vec<usize>,
pub unlocked_backgrounds: Vec<usize>,
pub last_modified: DateTime<Utc>,
}
pub struct AchievementRecord {
pub id: String,
pub unlocked: bool,
pub unlock_date: Option<DateTime<Utc>>,
pub reward_granted: bool,
}
pub struct Settings {
pub draw_mode: DrawMode,
pub sfx_volume: f32, // 0.0–1.0
pub music_volume: f32,
pub animation_speed: AnimSpeed,
pub theme: Theme,
pub sync_backend: SyncBackend, // Local | SolitaireServer
pub selected_card_back: usize, // index into PlayerProgress::unlocked_card_backs
pub selected_background: usize, // index into PlayerProgress::unlocked_backgrounds
pub first_run_complete: bool,
pub color_blind_mode: bool, // blue tint on red suits
pub high_contrast_mode: bool, // boosted luminance for low-vision users
pub reduce_motion_mode: bool, // WCAG reduce-motion — snaps instead of slides
pub window_geometry: Option<WindowGeometry>, // persisted size + position; None on first run
}
pub struct WindowGeometry {
pub width: u32, // logical pixels
pub height: u32,
pub x: i32, // physical pixels, top-left corner
pub y: i32,
}
9. API Reference
All endpoints are under the base URL configured by the user (e.g., https://solitaire.example.com).
Authentication
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/auth/register |
None | {username, password} |
{access_token, refresh_token} |
| POST | /api/auth/login |
None | {username, password} |
{access_token, refresh_token} |
| POST | /api/auth/refresh |
None | {refresh_token} |
{access_token, refresh_token} (rotated) |
Sync
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /api/sync/pull |
Bearer JWT | — | SyncResponse |
| POST | /api/sync/push |
Bearer JWT | SyncPayload |
SyncResponse |
Game Data
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| GET | /api/daily-challenge |
None | — | ChallengeGoal |
| GET | /api/leaderboard |
Bearer JWT | — | Vec<LeaderboardEntry> |
| POST | /api/leaderboard/opt-in |
Bearer JWT | — | {ok: true} |
Replays
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| POST | /api/replays |
Bearer JWT | Replay JSON | {id, share_url} |
| GET | /api/replays/recent |
None | — (?limit=N) |
Vec<ReplaySummary> |
| GET | /api/replays/:id |
None | — | Full Replay JSON |
Web Replay Player
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /replays/:id |
None | Serves web/index.html; JS fetches /api/replays/:id and steps through via the solitaire_wasm WASM module |
| GET | /web/* |
None | Static assets served via ServeDir from solitaire_server/web/ (includes web/pkg/ with wasm-bindgen output) |
Account Management
| Method | Path | Auth | Body | Response |
|---|---|---|---|---|
| DELETE | /api/account |
Bearer JWT | — | {ok: true} |
| GET | /health |
None | — | {status, version} |
JWT Details
- Access token expiry: 24 hours
- Refresh token expiry: 30 days
- Algorithm: HS256
- Secret:
JWT_SECRETenvironment variable (min 64 chars recommended)
10. Merge Strategy
Used by both SolitaireServerClient and the server-side handler. Lives in solitaire_sync as a pure function with no I/O.
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> SyncPayload {
SyncPayload {
stats: StatsSnapshot {
games_played: max(local.stats.games_played, remote.stats.games_played),
games_won: max(local.stats.games_won, remote.stats.games_won),
games_lost: max(local.stats.games_lost, remote.stats.games_lost),
win_streak_best: max(local.stats.win_streak_best, remote.stats.win_streak_best),
win_streak_current: max(local.stats.win_streak_current, remote.stats.win_streak_current),
fastest_win_seconds: min(local.stats.fastest_win_seconds, remote.stats.fastest_win_seconds),
best_single_score: max(local.stats.best_single_score, remote.stats.best_single_score),
lifetime_score: max(local.stats.lifetime_score, remote.stats.lifetime_score),
// avg_time recomputed from merged games_played/total_time
last_modified: Utc::now(),
..
},
achievements: union_by_id( // never remove an unlocked achievement
&local.achievements, // keep earliest unlock_date on conflict
&remote.achievements,
),
progress: PlayerProgress {
total_xp: max(local.progress.total_xp, remote.progress.total_xp),
unlocked_card_backs: union_vecs(&local.progress.unlocked_card_backs, &remote.progress.unlocked_card_backs),
unlocked_backgrounds: union_vecs(&local.progress.unlocked_backgrounds, &remote.progress.unlocked_backgrounds),
// level recomputed from merged total_xp
last_modified: Utc::now(),
..
},
last_modified: Utc::now(),
..
}
}
Conflict reporting: Any case where local and remote have different values for the same field that cannot be merged deterministically (e.g., different daily challenge streak counts) is recorded in Vec<ConflictReport> and returned to the client for display — data is never silently discarded.
11. Achievement System
Definition Structure
Achievements are defined as static data in solitaire_core. Runtime unlock state (unlocked, unlock_date, reward_granted) is stored separately in solitaire_data.
pub struct AchievementDef {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub icon: &'static str,
pub secret: bool,
pub reward: Option<Reward>,
pub condition: fn(&GameState, &StatsSnapshot, &PlayerProgress) -> bool,
}
Achievement List
| ID | Name | Condition | Secret | Reward |
|---|---|---|---|---|
first_win |
First Win | Win 1 game | No | — |
on_a_roll |
On a Roll | Win streak ≥ 3 | No | Card back #1 |
unstoppable |
Unstoppable | Win streak ≥ 10 | No | Background #1 |
century |
Century | 100 games played | No | — |
veteran |
Veteran | 500 games played | No | Badge |
speed_demon |
Speed Demon | Win in < 3 min | No | — |
lightning |
Lightning | Win in < 90 sec | No | Card back #2 |
high_scorer |
High Scorer | Score ≥ 5,000 | No | — |
point_machine |
Point Machine | Lifetime score ≥ 50,000 | No | Background #2 |
no_undo |
No Undo | Win without undo | No | +25 XP |
draw_three_master |
Draw 3 Master | 10 Draw 3 wins | No | Card back #3 |
perfectionist |
Perfectionist | Max possible score | No | Badge |
night_owl |
Night Owl | Play after midnight | No | — |
early_bird |
Early Bird | Play before 6am | No | — |
daily_devotee |
Daily Devotee | 7 daily challenges | No | Background #3 |
speed_and_skill |
??? | Win < 90s without undo | Yes | Card back #4 |
comeback |
??? | Win after 3+ stock recycles | Yes | Background #4 |
zen_winner |
??? | Win in Zen Mode | Yes | Badge |
cinephile |
Cinephile | Watch a saved replay all the way through | No | — |
Evaluation Timing
Achievement conditions are evaluated by AchievementPlugin on every GameWonEvent and StateChangedEvent. The plugin calls solitaire_core::check_achievements() which returns a Vec<AchievementDef> of newly unlocked achievements. The plugin then fires AchievementUnlockedEvent for each, which the toast and persistence systems handle independently.
A small number of achievements are event-driven rather than condition-driven: their AchievementDef::condition always returns false and their unlock is written from a dedicated observer system instead. cinephile is the canonical example — it unlocks when ReplayPlaybackState transitions from Playing to Completed (a saved replay watched to its natural end). The Stop button transitions Playing → Inactive directly without entering Completed, so manual aborts do not unlock the achievement.
12. Progression System
XP Sources
| Action | XP Awarded |
|---|---|
| Win a game | +50 |
| Fast win bonus (< 2 min) | +10 to +50 (scaled) |
| Win without undo | +25 |
| Complete daily challenge | +100 |
| Complete weekly goal | +75 |
Level Formula
Levels 1–10: level = floor(total_xp / 500)
Levels 11+: level = 10 + floor((total_xp - 5000) / 1000)
Special Modes (unlocked at level 5)
| Mode | Rules |
|---|---|
| Time Attack | Play as many games as possible in 10 minutes. Score = total wins. |
| Challenge Mode | Fixed hard seeds. No undo. Must win to advance. |
| Zen Mode | No timer. No score display. Ambient audio. No penalties. |
13. Audio System
Audio uses kira. All sound files are .wav.
| File | Trigger |
|---|---|
card_deal.wav |
New game deal animation |
card_flip.wav |
Card flips face-up |
card_place.wav |
Valid card placement |
card_invalid.wav |
Invalid move attempt |
win_fanfare.wav |
Game won |
ambient_loop.wav |
Looping background music |
Volume is controlled by two independent sliders in Settings (sfx_volume, music_volume), each stored in Settings and applied as kira channel volumes.
Audio systems listen for Bevy events and never block the game thread.
14. Asset Pipeline
Rendering approach
Cards are Bevy Sprite entities with Handle<Image> from CardImageSet. Face-up cards use one of 52 individual face PNGs selected by faces[suit][rank] — rank and suit are baked into each image and no Text2d overlay is spawned. Face-down cards use backs/back_N.png indexed by settings.selected_card_back. Text2d labels are only used as a fallback when CardImageSet is absent (e.g. tests with MinimalPlugins). CardImageSet is populated at startup by card_plugin::load_card_images via AssetServer::load().
Backgrounds are Bevy Sprite entities with Handle<Image> from BackgroundImageSet. BackgroundImageSet is populated at startup by table_plugin::load_background_images via AssetServer::load().
The font FiraMono-Medium is loaded via AssetServer::load("fonts/main.ttf") at startup by FontPlugin and exposed as FontResource for use by all UI and text systems.
All three loaders take Option<Res<AssetServer>> so they degrade cleanly under MinimalPlugins in tests: when the server is absent, CardImageSet/BackgroundImageSet are inserted with empty handle slots and the plugins fall back to Text2d rank+suit overlays and solid-colour board backgrounds. The assets/ directory must ship alongside the binary.
The assets/ directory layout:
assets/
├── cards/
│ ├── faces/{rank}_{suit}.png # 52 individual card faces (120×168, generated by solitaire_assetgen)
│ └── backs/back_0.png – back_4.png # placeholder patterns
├── backgrounds/bg_0.png – bg_4.png # placeholder textures
├── fonts/main.ttf # FiraMono-Medium (170K, OFL)
└── audio/
├── card_deal.wav
├── card_flip.wav
├── card_place.wav
├── card_invalid.wav
├── win_fanfare.wav
└── ambient_loop.wav
Audio
All sound effect WAV files are embedded at compile time via include_bytes!() in audio_plugin.rs. There is no runtime asset loading — the binary is fully self-contained.
| File | Trigger |
|---|---|
card_deal.wav |
New game deal animation |
card_flip.wav |
Card flips face-up |
card_place.wav |
Valid card placement |
card_invalid.wav |
Invalid move attempt |
win_fanfare.wav |
Game won |
ambient_loop.wav |
Looping background music |
15. Platform Targets
| Platform | Status | Primary Sync | Notes |
|---|---|---|---|
| macOS | Primary | Self-hosted server | x86_64 + Apple Silicon (universal binary via cargo-lipo) |
| Windows | Primary | Self-hosted server | x86_64, MSVC toolchain |
| Linux | Primary | Self-hosted server | x86_64, tested on Ubuntu 22.04+ and Fedora 39+ |
| Android | Active | Self-hosted server | cargo-apk; touch + long-press + double-tap; safe-area JNI; portrait layout |
| iOS | Stretch | Self-hosted server | cargo-mobile2, touch input |
Minimum Bevy window size enforced: 800×600. Desktop windows are freely resizable; layout recomputes on WindowResized.
16. Build & Development Guide
Prerequisites
- Rust stable toolchain (via
rustup) - For Linux:
libasound2-dev,libudev-dev,libxkbcommon-dev - For macOS: Xcode Command Line Tools
Common Commands
# Run the game (dev build with dynamic linking for fast compile)
cargo run -p solitaire_app --features bevy/dynamic_linking
# Run with release optimizations
cargo run -p solitaire_app --release
# Run all tests
cargo test --workspace
# Lint (must pass clean — no warnings allowed)
cargo clippy --workspace -- -D warnings
# Run the sync server locally
cargo run -p solitaire_server
# Build release binaries for all crates
cargo build --workspace --release
Environment Variables (Server)
Copy .env.example to .env and fill in:
DATABASE_URL=sqlite://solitaire.db
JWT_SECRET=<generate with: openssl rand -hex 32>
SERVER_PORT=8080
ADMIN_USERNAME=admin # optional, seeded on first run
Fast Compile Setup
The workspace Cargo.toml includes:
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
[profile.release]
opt-level = 3
lto = "thin"
Add --features bevy/dynamic_linking during development to dramatically reduce incremental compile times.
17. Deployment Guide
Docker Compose (Recommended)
git clone https://github.com/yourname/solitaire_quest
cd solitaire_quest
cp .env.example .env
# Edit .env — set JWT_SECRET and SERVER_PORT
docker compose up -d
This starts the sync server + a Caddy reverse proxy with automatic TLS (provide your domain in docker-compose.yml).
Systemd Service (Alternative)
cargo build -p solitaire_server --release
sudo cp target/release/solitaire_server /usr/local/bin/
sudo cp solitaire_server.service /etc/systemd/system/
sudo systemctl enable --now solitaire_server
Backups
The entire server state is in a single SQLite file. Back it up by copying it:
sqlite3 solitaire.db ".backup backup_$(date +%Y%m%d).db"
Or just cp solitaire.db backups/ — SQLite's WAL mode makes this safe while the server is running.
Updating
git pull
cargo build -p solitaire_server --release
sudo systemctl restart solitaire_server
Migrations run automatically on startup via sqlx::migrate!().
18. Security Model
| Concern | Mitigation |
|---|---|
| Password storage | bcrypt, cost factor 12 — never stored in plaintext |
| Token security | JWTs signed with HS256, stored in OS keychain via keyring crate |
| Token expiry | Access: 24h, Refresh: 30d |
| Refresh token rotation | Each /api/auth/refresh call consumes the incoming refresh token (deletes its jti row) and issues a new one. Reuse of a consumed token returns 401. Expired rows are pruned inline. |
| Brute force | tower-governor: 10 req/min per IP on /api/auth/* |
| Payload abuse | 1MB max request body, enforced by Axum middleware |
| Data deletion | DELETE /api/account removes all rows via ON DELETE CASCADE |
| TLS | Handled by reverse proxy (Caddy/nginx) — server runs plain HTTP internally |
| PII | Only username stored — no email, no real name required |
| Leaderboard | Opt-in only — display name chosen by user at opt-in time |
19. Testing Strategy
Unit Tests (solitaire_core)
Every public function in solitaire_core has corresponding #[test] coverage:
- All legal move types (tableau→foundation, waste→tableau, etc.)
- All illegal move types and their
MoveErrorvariants - Undo: state fully restored after 1, 5, and 64 undos
- Scoring: each action type, time bonus formula, floor at zero
- Win detection: true positive (complete foundation), true negative
- Auto-complete detection
- Seeded deal: same seed produces identical layout across 100 runs
Unit Tests (solitaire_sync)
- Merge: each field merges correctly (max, min, union)
- Merge: idempotent (merging identical payloads returns identical payload)
- Merge: achievements never removed
Integration Tests (solitaire_server)
Using axum::test and an in-memory SQLite database:
- Auth flow: register → login → access protected endpoint → refresh → access again
- Sync roundtrip: push payload → pull → verify merged response
- Rate limiting: 11th request within 1 minute returns 429
- Account deletion: all rows removed, subsequent JWT rejected
Manual Test Checklist (per platform, per release)
- New game deals correctly, all 52 cards present
- Drag and drop works for all pile type combinations
- Win triggers cascade animation and score display
- Undo restores previous state visually and in data
- Stats persist across app restart
- Achievement toast appears and dismisses
- Server sync: register, login, push, pull on second machine
- Server sync: JWT refresh on 401 works transparently
20. Decision Log
| Decision | Rationale | Date |
|---|---|---|
| Bevy as game engine | Best-in-class Rust game engine; ECS architecture suits card game structure well; active ecosystem | 2026-04-19 |
| SQLite over Postgres for server | Single-file DB simplifies self-hosting enormously; a card game sync server will never need Postgres-scale throughput | 2026-04-19 |
Shared solitaire_sync crate |
Ensures client and server types are always identical; type errors caught at compile time rather than runtime | 2026-04-19 |
keyring for credential storage |
OS keychain is the correct place for secrets on all three desktop platforms; never store JWTs or passwords in plaintext files | 2026-04-19 |
| Atomic file writes (tmp → rename) | Prevents corrupt save files on crash or power loss with zero extra dependencies | 2026-04-19 |
| bcrypt cost 12 | Balances security and registration latency (~300ms on modern hardware); higher than default 10 | 2026-04-19 |
| No email required for server accounts | Reduces PII collected; simplifies self-hosted deployments; password reset handled by server admin if needed | 2026-04-19 |
| Self-hosted server as primary sync (not WebDAV) | A proper Rust server gives us auth, leaderboards, and daily challenge seeding for minimal extra effort over WebDAV, and removes a redundant backend | 2026-04-20 |
SyncProvider trait, not SyncBackend match arms |
SyncPlugin stays backend-agnostic and testable; new backends can be added without touching the plugin |
2026-04-20 |
| Dropped WebDAV backend | Redundant once the self-hosted server exists; removing it reduces surface area and simplifies settings UI | 2026-04-20 |
| Dropped GPGS backend | Redundant with the self-hosted server; adds JNI complexity for no user-visible benefit on the target platforms | 2026-04-28 |
Card, background, and font assets loaded via AssetServer |
Reverses the earlier embed-via-include_bytes!() decision: PNGs and TTFs are loaded at runtime so artwork can be swapped (e.g. alternate card backs, themed backgrounds) without a recompile, and binary size stays small. Loaders take Option<Res<AssetServer>> and fall back gracefully under MinimalPlugins. The assets/ directory must ship alongside the binary. |
2026-04-29 |
Audio assets remain embedded via include_bytes!() |
Audio files are small, change rarely, and the embedded path eliminates a class of runtime-load errors during gameplay; the asset-pipeline reversal does not extend to audio | 2026-04-29 |
| Card art swapped from xCards (LGPL-3.0) to hayeah/playing-cards-assets (MIT) | Public-release readiness. The previous xCards art carried LGPL relinking obligations that complicate a single-binary distribution; hayeah's set derives from the public-domain vector-playing-cards line-art and is permissively MIT-licensed. CREDITS.md license summary collapsed to MIT + OFL-1.1. The default card back is original work in this project's midnight-purple palette. |
2026-05-01 |
Runtime SVG card-theme system (CARD_PLAN.md) |
User-supplied themes need to ship SVG sources so they can rasterise at any resolution on the player's hardware; baking PNGs at build time only would lock theme installation to the developer. The pipeline (usvg → resvg → tiny-skia) rasterises once per (theme, target size) at load time and caches the resulting Image, so the runtime cost is paid once, not per frame. The bundled default theme ships via embedded://; user themes via themes:// rooted at user_theme_dir(). |
2026-05-01 |