# 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:
```