Klondike Solitaire¶
Full Klondike with drag-drop spring/tilt, undo, save/load.
▶ Run in browserUpstream: https://github.com/zaccnz/solitaire
Tags: port tier-1
Klondike Solitaire: SimVX port¶
Port of 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):
# 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
<cwd>/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
CardNodeper physical card, all parented toTableNodefrom start. When a card moves between piles, its parent never changes – only its target position andz_index. This avoidsadd_child/remove_childthrash 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.GameStateis 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 justto_dict/from_dict.Polled input. Per Balatro-Feel’s NOTES,
@on_inputdecorators don’t fire fromInputSimulator– the polling path (Input.is_mouse_button_*) works in both real-platform and headless harness modes. So the full input pipeline lives inTableNode.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 byabsolute_z_indexso this is sufficient – no manual reparenting needed.JSON save instead of
SaveManager. The engine’sSaveManagerwalksProperty(persist=True)descriptors. The game’s deck order, move history, and per-cardface_upstate aren’t naturally Property values – they live in mutable Python lists. Easier and faster to serialise the live state to JSON viaGameState.to_dict().
Visual reference¶
Idle headless captures (--test):
File |
Stage |
|---|---|
|
Initial deal – cards still settling |
|
Cards settled, full tableau visible |
|
Static idle scene |
Scripted harness (harness.py):
File |
Stage |
|---|---|
|
Initial deal complete |
|
3 cards drawn from stock onto waste |
|
Mid-drag of waste card toward foundation, with tilt |
|
Drop attempt resolved (springs back if illegal) |
|
After pressing ‘U’ – one stock-cycle undone |
|
Synthesised end-game: 4 Kings on foundations + K♣ on waste – one click from win |
|
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¶
1"""Klondike Solitaire: Full Klondike with drag-drop spring/tilt, undo, save/load.
2
3# /// simvx
4# tags = ["port", "tier-1"]
5# upstream = "https://github.com/zaccnz/solitaire"
6# web = { width = 1280, height = 720, responsive = true }
7# ///
8
9Run:
10 uv run python ported_games/solitaire/simvx_port/main.py # interactive
11 uv run python ported_games/solitaire/simvx_port/main.py --test # headless capture
12"""
13
14from __future__ import annotations
15
16import sys
17from pathlib import Path
18
19_PORT_DIR = Path(__file__).parent
20if str(_PORT_DIR) not in sys.path:
21 sys.path.insert(0, str(_PORT_DIR))
22
23from nodes.hud import Hud # noqa: E402
24from nodes.save_io import load_game, save_game # noqa: E402
25from nodes.table import TableNode # noqa: E402
26
27from simvx.core import Node2D # noqa: E402
28from simvx.core.input.enums import Key, MouseButton # noqa: E402
29from simvx.core.input.map import InputMap # noqa: E402
30from simvx.core.math.types import Vec2 # noqa: E402
31from simvx.graphics import App # noqa: E402
32
33WIDTH = 1280
34HEIGHT = 720
35
36
37class SolitaireRoot(Node2D):
38 """Root scene: green felt background + table + bottom HUD."""
39
40 def on_ready(self) -> None:
41 # Input actions live in root.on_ready (web exporter skips main()).
42 InputMap.add_action("undo", [Key.U, Key.Z])
43 InputMap.add_action("new_game", [Key.N])
44 InputMap.add_action("save", [Key.S])
45 InputMap.add_action("load", [Key.L])
46 InputMap.add_action("primary", [MouseButton.LEFT])
47 InputMap.add_action("secondary", [MouseButton.RIGHT])
48
49 # Try restoring a previous save on launch
50 loaded = load_game()
51 self.table = TableNode(state=loaded)
52 self.add_child(self.table)
53 self.table.state_changed.connect(self._on_state_changed)
54 self.table.won.connect(self._on_won)
55
56 self.hud = Hud(viewport_size=Vec2(WIDTH, HEIGHT))
57 self.add_child(self.hud)
58 self.hud.button_pressed.connect(self._on_button)
59
60 self._on_state_changed()
61
62 # -------------------------------------------------------------- draw
63 def on_draw(self, renderer) -> None:
64 # Green felt background
65 renderer.draw_rect(
66 (0, 0), (WIDTH, HEIGHT),
67 colour=(0.10, 0.36, 0.20, 1.0),
68 filled=True,
69 )
70 # Subtle vignette via two darker rects on the sides
71 renderer.draw_rect(
72 (0, 0), (WIDTH, 80),
73 colour=(0.07, 0.28, 0.16, 1.0), filled=True,
74 )
75
76 # -------------------------------------------------------------- glue
77 def _on_state_changed(self) -> None:
78 st = self.table.state
79 self.hud.set_state(st.score, st.moves, st.is_won)
80
81 def _on_won(self) -> None:
82 self.hud.set_state(self.table.state.score, self.table.state.moves, True)
83
84 def _on_button(self, action: str) -> None:
85 if action == "new_game":
86 self.table.action_new_game()
87 elif action == "undo":
88 self.table.action_undo()
89 elif action == "save":
90 save_game(self.table.state)
91 elif action == "load":
92 loaded = load_game()
93 if loaded is not None:
94 self.table.state = loaded
95 self.table._dragging = []
96 self.table._initial_layout = True
97 self.table.state_changed()
98 elif action == "quit":
99 self.app.quit()
100
101
102def main() -> None:
103 headless = "--test" in sys.argv
104 if headless:
105 from simvx.graphics import save_png
106
107 capture_at = [30, 60, 120]
108 app = App(width=WIDTH, height=HEIGHT, title="Klondike Solitaire (SimVX)", visible=False)
109 frames = app.run_headless(SolitaireRoot(), frames=130, capture_frames=capture_at)
110 out_dir = _PORT_DIR / "screenshots"
111 out_dir.mkdir(exist_ok=True)
112 for idx, img in zip(capture_at, frames, strict=False):
113 out_path = out_dir / f"frame_{idx}.png"
114 save_png(out_path, img)
115 print(f"saved {out_path}")
116 else:
117 app = App(width=WIDTH, height=HEIGHT, title="Klondike Solitaire (SimVX)")
118 app.run(SolitaireRoot())
119
120
121if __name__ == "__main__":
122 main()