Art pass (Phase 4): - Generate placeholder PNG assets: face.png, back_0–4.png, bg_0–4.png via solitaire_assetgen gen_art binary (16×16 RGBA, embedded via include_bytes!) - Add FiraMono-Medium font (assets/fonts/main.ttf) embedded at compile time - Add FontPlugin: loads font at startup, exposes FontResource; gracefully falls back to default handle when Assets<Font> absent (MinimalPlugins tests) - Wire CardImageSet into card_plugin: face/back PNGs replace solid-colour sprites when available; tests continue using colour fallback via MinimalPlugins - Wire BackgroundImageSet into table_plugin: bg PNGs replace solid-colour background; empty set inserted when Assets<Image> absent in tests - Fix hint highlight system (input_plugin): tint sprite.color directly instead of replacing the whole Sprite (which would discard the image handle) - Export FontPlugin, FontResource, CardImageSet from solitaire_engine::lib - Register FontPlugin in solitaire_app before other plugins Dependency upgrades (latest releases): - keyring "2" → keyring "4" + keyring-core "1" (v4 split architecture into separate core library crate) - auth_tokens.rs: Entry::new now returns Result; delete_password → delete_credential; NoDefaultStore error variant handled - solitaire_app: add keyring::use_native_store(true) at startup for Linux Secret Service / macOS Keychain / Windows Credential Store selection ARCHITECTURE.md: fix Edition 2025→2021, update asset pipeline section, add FontPlugin/CardImageSet/BackgroundImageSet to plugin and resource tables, update Section 14 to reflect actual include_bytes!() rendering approach, add Decision Log entries for embedded PNG and font decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
35 KiB
Solitaire Quest — Architecture Document
Version: 1.1
Language: Rust (Edition 2021)
Engine: Bevy (latest stable)
Last Updated: 2026-04-29
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
Solitaire Quest 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 |
Design Principles
- Offline first. The local file is always the source of truth. Sync is additive, never destructive.
- Pure core. All game logic lives in a dependency-free Rust crate with no Bevy, no network, and no I/O. This keeps it fully unit-testable and portable.
- No panics in game logic. Every state transition returns
Result<_, MoveError>. Panics are only acceptable in startup/configuration code. - 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.
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/ # Assets embedded at compile time via include_bytes!()
│ ├── cards/
│ │ ├── faces/face.png # placeholder (16×16 cream/ivory)
│ │ └── 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
│
├── 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_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, bevy_kira_audio, 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 (embedded via
include_bytes!()— noAssetServerdependency)
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_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
| Plugin | Key | Responsibility |
|---|---|---|
CardPlugin |
— | Card entity spawning, sprite management, drag-and-drop |
TablePlugin |
— | Pile markers, background, layout calculation |
FontPlugin |
— | Embeds FiraMono-Medium font at compile time; 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 bevy_kira_audio |
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 |
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 |
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 {
face: Handle<Image>, // shared face image for all face-up cards
backs: [Handle<Image>; 5], // indexed by selected_card_back setting
}
// Project-wide font handle (FiraMono-Medium embedded at compile time)
struct FontResource(Handle<Font>);
// Pre-loaded background PNG handles
struct BackgroundImageSet {
handles: Vec<Handle<Image>>, // indices 0–4 match selected_background setting
}
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 {
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;
}
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)
);
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 first_run_complete: bool,
}
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} |
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} |
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 |
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.
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 bevy_kira_audio. 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 bevy_kira_audio 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 face.png (a single shared image). Face-down cards use backs/back_N.png indexed by settings.selected_card_back. Text2d labels are still overlaid for rank and suit symbols. CardImageSet is populated at startup from include_bytes!() — no AssetServer.
Backgrounds are Bevy Sprite entities with Handle<Image> from BackgroundImageSet. BackgroundImageSet is populated at startup from include_bytes!().
The font FiraMono-Medium is embedded via include_bytes!() at startup by FontPlugin and exposed as FontResource for use by all UI and text systems.
The assets/ directory layout:
assets/
├── cards/
│ ├── faces/face.png # placeholder (16×16 cream/ivory)
│ └── 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 |
Future art pass
The placeholder PNG files can be replaced with real artwork without any code changes — just drop in new PNGs and rebuild. The texture atlas approach described below is still the recommended upgrade path for card faces:
- Use a texture atlas (
assets/cards/atlas.png+ layout descriptor) for card faces - Individual PNGs for card backs and backgrounds (5 each)
- All assets remain embedded via
include_bytes!()to keep the binary self-contained
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 | Stretch | Self-hosted server | cargo-mobile2, touch input |
| 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 |
| 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 |
PNG assets embedded via include_bytes!() |
Using Image::from_buffer() in startup systems rather than AssetServer::load() keeps the binary self-contained and eliminates runtime file-not-found errors |
2026-04-29 |
FiraMono-Medium font embedded via include_bytes!() |
Exposed through FontResource; avoids runtime font loading errors on headless systems and ensures consistent text rendering across all platforms |
2026-04-29 |