# Klondike Solitaire Full Klondike with drag-drop spring/tilt, undo, save/load. ```{raw} html ▶ Run in browser

Upstream: https://github.com/zaccnz/solitaire

``` **Tags:** `port` `tier-1` # Klondike Solitaire: SimVX port Port of [zaccnz/solitaire](https://github.com/zaccnz/solitaire) (a small C/raylib solitaire) to SimVX. Tier 1 Port #9, the "UI baseline" exercise: drag-drop cards, animated transitions, save/load, win condition, undo. No real-time pressure: solitaire is a perfect baseline for testing the engine's UI plumbing. ## Run All commands run from `/home/fezzik/dev/simvx` (uv workspace root): ```bash # Interactive uv run python ported_games/solitaire/simvx_port/main.py # Headless test capture (frames 30/60/120 -> screenshots/) uv run python ported_games/solitaire/simvx_port/main.py --test # Scripted-input harness (deal -> draw -> drag -> drop -> undo -> near-win -> WIN) uv run python ported_games/solitaire/simvx_port/harness.py # Web export uv run simvx export web /home/fezzik/dev/ported_games/solitaire/simvx_port/main.py \ -o /home/fezzik/dev/ported_games/solitaire/simvx_port/web/index.html ``` ## Controls - **Click & drag** any face-up card (or stack from a tableau column) onto a legal pile. - **Click without drag** auto-moves the card (foundation first, then tableau) if a legal destination exists. - **Click stock** to deal one card to the waste; once empty, click stock to recycle the waste. - **U / Z**: Undo the last move (stock cycle, recycle, or card move). - **Bottom strip buttons**: *New game*, *Undo*, *Save*, *Load*, *Quit*. - Save files go to `/saves/klondike.json` (atomic write, one rotated `.bak`). ## Mobile / touch The web runtime surfaces touchstart/move/end as `MouseButton.LEFT`, so the desktop drag-drop pipeline works identically on touch devices. There are no keyboard-only shortcuts in the core game: every action (deal, move, undo via the *Undo* button, new game) is reachable with a pointer. ## File map ``` simvx_port/ ├── main.py # SolitaireRoot scene + InputMap + headless --test mode ├── harness.py # 7-stage scripted capture (deal/draw/drag/drop/undo/win) ├── pyproject.toml # [tool.simvx] root = "SolitaireRoot" ├── nodes/ │ ├── card_textures.py # Procedural card faces (rounded rect + freetype rank + │ │ vector suit pip), face-down back, empty-slot placeholder │ ├── card_node.py # CardNode -- spring-following Sprite2D card visual │ ├── game_state.py # Pure-logic GameState (tableau, foundations, stock, waste, │ │ history, save/load helpers, move validation) │ ├── save_io.py # JSON game-save persistence (atomic write + .bak) │ ├── table.py # TableNode -- layout, hit-testing, drag/drop, undo wiring │ └── hud.py # Bottom-strip controls + score/move counter + win banner ├── screenshots/ # 3 idle (frame_30/60/120) + 7 harness stages └── web/index.html # Pyodide bundle (~2.5 MB, root auto-detected) ``` ## Architecture choices - **One `CardNode` per physical card, all parented to `TableNode` from start.** When a card moves between piles, its parent never changes -- only its target position and `z_index`. This avoids `add_child`/`remove_child` thrash during drag-drop and keeps the scene-tree topology stable across the entire game. The springy follow + per-frame target update mirrors Balatro-Feel's approach. - **`GameState` is the source of truth.** All move validation (tableau colour alternation, foundation suit-ascending) lives in pure Python with no node references. The state can be JSON-serialised and restored without touching the visual tree -- save/load is just `to_dict` / `from_dict`. - **Polled input.** Per Balatro-Feel's NOTES, `@on_input` decorators don't fire from `InputSimulator` -- the polling path (`Input.is_mouse_button_*`) works in both real-platform and headless harness modes. So the full input pipeline lives in `TableNode.on_process(dt)`. - **Drag rendering via `z_index`.** Cards being dragged jump to a high z band (1000+) so they always render above siblings. The engine sorts children by `absolute_z_index` so this is sufficient -- no manual reparenting needed. - **JSON save instead of `SaveManager`.** The engine's `SaveManager` walks `Property(persist=True)` descriptors. The game's deck order, move history, and per-card `face_up` state aren't naturally Property values -- they live in mutable Python lists. Easier and faster to serialise the live state to JSON via `GameState.to_dict()`. ## Visual reference Idle headless captures (`--test`): | File | Stage | |---|---| | `screenshots/frame_30.png` | Initial deal -- cards still settling | | `screenshots/frame_60.png` | Cards settled, full tableau visible | | `screenshots/frame_120.png` | Static idle scene | Scripted harness (`harness.py`): | File | Stage | |---|---| | `01_deal.png` | Initial deal complete | | `02_drawn.png` | 3 cards drawn from stock onto waste | | `03_drag.png` | Mid-drag of waste card toward foundation, with tilt | | `04_drop.png` | Drop attempt resolved (springs back if illegal) | | `05_undo.png` | After pressing 'U' -- one stock-cycle undone | | `06_nearwin.png` | Synthesised end-game: 4 Kings on foundations + K♣ on waste -- one click from win | | `07_won.png` | Click resolved -> "YOU WIN!" banner shown | ## Acceptance bar - [x] Source cloned to `source/` - [x] Game launches from `main.py` - [x] Full Klondike playable end-to-end (deal, stock cycle, drag tableau/foundation, win) - [x] Drag-drop responsive with smooth tweens (spring follow + rotational tilt) - [x] Undo works (stack-based, full move history) - [x] Save/load via atomic JSON write - [x] Headless screenshots at frame 30/60/120 - [x] Scripted harness covering 7 stages - [x] Web export produces a 2.5 MB HTML - [x] NOTES.md written ## Source ```{literalinclude} ../../examples/ports/solitaire/main.py :language: python :linenos: ```