Klondike Solitaire¶
drag-drop cards with springy motion, undo, and save/load.
▶ Run in browserUpstream: https://github.com/zaccnz/solitaire
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-1
Klondike Solitaire: SimVX port¶
A clean-room SimVX re-implementation of Klondike, inspired by zaccnz/solitaire (a small C/raylib solitaire). Drag-drop cards with springy motion, tap-to-auto-move, full undo, standard Klondike scoring, and JSON save/load, wrapped in a title menu and a bottom controls strip built from the engine’s own UI widgets.
Run¶
All commands run from the repository root:
# Interactive
uv run python examples/ports/solitaire/main.py
# Headless capture (frames 30/60/120 -> screenshots/)
uv run python examples/ports/solitaire/main.py --test
# Scripted-input harness (menu -> deal -> draw -> drag -> drop -> undo -> near-win -> WIN)
uv run python examples/ports/solitaire/harness.py
# Web export
uv run simvx export web examples/ports/solitaire/main.py -o /tmp/solitaire.html
Controls¶
Title menu: New game, Continue saved game (shown only when a save exists), Quit. Nothing is restored from disk unless you ask for it.
Click & drag any face-up card (or a 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). N: new game. S: save. L: load.
Bottom strip buttons: New game, Undo, Save, Load, Quit.
Save files go to
<cwd>/saves/klondike.json, atomically written with one rotated.bak.
Scoring¶
Standard (non-Vegas) Klondike scoring, never dropping below zero: +10 for a card onto a foundation, +5 from the waste to a tableau column, +5 for turning a tableau card face-up, -15 for taking a card back off a foundation, -100 for recycling the waste. Each move records the delta it actually applied, so undo reverses scoring exactly.
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 actions: every action (deal, move, undo, new game, save, load)
is reachable with a pointer.
File map¶
main.py # SolitaireRoot scene + keyboard actions + headless --test mode
harness.py # 7-stage scripted capture driven by InputSimulator
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, scoring, move validation, to_dict/from_dict)
├── save_io.py # JSON game-save persistence (atomic write + .bak)
├── table.py # TableNode: layout, hit-testing, drag/drop, undo wiring
├── menu.py # Title menu (New game / Continue / Quit)
└── hud.py # Bottom controls strip + scoreboard + win banner
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.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, and every entry point that swaps it goes throughTableNode.load_state.Polled input for the table, UI events for the chrome. A drag is a per-frame quantity (the held cards need the current cursor position every frame), so
TableNode.on_updatereads the pointer in the same pass that writes the card targets. The menu and the controls strip aresimvx.core.uiwidgets and get their clicks through the normal UI event path.One design box, fitted to the window. The table is authored in a fixed 1280x720 space and
TableNode.fit_viewportscales and centres it into whatever the real viewport is, mapping the pointer back into design space once per frame. The menu and HUD are anchored Controls, so they re-layout on resize for free.Drag rendering via
z_index. Cards being dragged jump to a dedicated z band (1000+) so they clear the tableau and foundation piles, and the HUD sits above every card at 5000. The engine sorts children byabsolute_z_index, so no reparenting is needed.JSON save instead of
SaveManager. The engine’sSaveManagerwalksProperty(persist=True)descriptors. The deck order, move history, and per-cardface_upstate are not naturally Property values: they live in mutable Python lists. SerialisingGameState.to_dict()to JSON is simpler and keeps the save file readable. The crash-safe write itself is the engine’ssimvx.core.io.atomic_write_text.
Source files¶
File |
Summary |
Lines |
|---|---|---|
Klondike Solitaire: drag-drop cards with springy motion, undo, and save/load. |
172 |
|
Scripted-input harness: exercises the menu, deal, click-to-draw, drag-drop, undo, and win. |
165 |
|
CardNode – visual representation of a single card. |
150 |
|
Procedural card textures for Klondike Solitaire. |
378 |
|
Pure-logic Klondike game state. |
356 |
|
Bottom controls strip, scoreboard, and win banner. |
94 |
|
Title menu shown before the first deal. |
95 |
|
JSON save/load for the live GameState. |
72 |
|
TableNode – the playing surface. |
434 |
Source¶
1"""Klondike Solitaire: drag-drop cards with springy motion, undo, and 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
9A clean-room SimVX re-implementation of Klondike, inspired by zaccnz/solitaire
10(C/raylib). It shows procedural ndarray card faces handed straight to Sprite2D,
11a pure-Python GameState mirrored onto a stable scene tree (one CardNode per
12card, never reparented), spring-follow motion with velocity tilt, drag-drop and
13tap-to-auto-move input, full undo history, standard Klondike scoring, atomic
14JSON saves, and a title menu plus bottom controls strip built from
15``simvx.core.ui`` widgets so the whole interface follows a window resize.
16
17Run:
18 uv run python examples/ports/solitaire/main.py # interactive
19 uv run python examples/ports/solitaire/main.py --test # headless capture
20
21Web export:
22 uv run simvx export web examples/ports/solitaire/main.py -o /tmp/solitaire.html
23"""
24
25from __future__ import annotations
26
27import sys
28from pathlib import Path
29
30_PORT_DIR = Path(__file__).parent
31if str(_PORT_DIR) not in sys.path:
32 sys.path.insert(0, str(_PORT_DIR))
33
34from nodes.hud import Hud # noqa: E402
35from nodes.menu import TitleMenu # noqa: E402
36from nodes.save_io import has_save, load_game, save_game # noqa: E402
37from nodes.table import DESIGN_H, DESIGN_W, TableNode # noqa: E402
38
39from simvx.core import Node2D # noqa: E402
40from simvx.core.input.enums import Key # noqa: E402
41from simvx.core.input.state import Input # noqa: E402
42from simvx.graphics import App # noqa: E402
43
44# Keyboard shortcuts. Declared on the root so the scene tree registers them on
45# every root swap, including on the web (where ``main()`` never runs).
46ACTIONS = {
47 "undo": [Key.U, Key.Z],
48 "new_game": [Key.N],
49 "save": [Key.S],
50 "load": [Key.L],
51}
52
53
54class SolitaireRoot(Node2D):
55 """Root scene: green felt background, table, title menu, and bottom HUD."""
56
57 input_actions = ACTIONS
58
59 def on_ready(self) -> None:
60 self._queued_entry: str | None = None
61 self.table = TableNode()
62 self.add_child(self.table)
63 self.table.state_changed.connect(self._on_state_changed)
64 self.table.won.connect(self._on_won)
65
66 self.hud = Hud()
67 self.add_child(self.hud)
68 self.hud.button_pressed.connect(self.run_action)
69 self.hud.visible = False
70
71 # Menu first: a save is only restored if the player asks for it.
72 self.menu = self.add_child(
73 TitleMenu(
74 on_new_game=lambda: self._queue_entry("new_game"),
75 on_continue=(lambda: self._queue_entry("load")) if has_save() else None,
76 on_quit=lambda: self._queue_entry("quit"),
77 )
78 )
79 self.table.interactive = False
80
81 self.tree.screen_resized.connect(self._on_screen_resized)
82 self._on_screen_resized(self.tree.screen_size)
83 self._on_state_changed()
84
85 # -------------------------------------------------------------- layout
86 def _on_screen_resized(self, size) -> None:
87 """Refit the table and repaint the felt. The HUD and menu are anchored."""
88 self.table.fit_viewport(size)
89 self.queue_redraw()
90
91 def on_draw(self, renderer) -> None:
92 width, height = self.tree.screen_size
93 # Green felt, with a darker band behind the stock/waste/foundation row.
94 renderer.draw_rect((0, 0), (width, height), colour=(0.10, 0.36, 0.20, 1.0), filled=True)
95 renderer.draw_rect((0, 0), (width, 80), colour=(0.07, 0.28, 0.16, 1.0), filled=True)
96
97 # -------------------------------------------------------------- glue
98 def on_update(self, dt: float) -> None:
99 if self._queued_entry is not None:
100 entry, self._queued_entry = self._queued_entry, None
101 self._enter_game(entry)
102 return
103 if self.menu.visible:
104 return
105 for action in ACTIONS:
106 if Input.is_action_just_pressed(action):
107 self.run_action(action)
108 # The controls strip sits on top of the table, so a click there must not
109 # also grab a card underneath it.
110 self.table.interactive = not self.hud.covers(Input.mouse_position)
111
112 def _queue_entry(self, entry: str) -> None:
113 """Menu buttons fire during input dispatch; act on the next update."""
114 self._queued_entry = entry
115
116 def _enter_game(self, entry: str) -> None:
117 if entry == "quit":
118 self.app.quit()
119 return
120 if entry == "load":
121 self.run_action("load")
122 else:
123 self.table.action_new_game()
124 self.menu.visible = False
125 self.hud.visible = True
126 self.table.interactive = True
127
128 def run_action(self, action: str) -> None:
129 """One handler for every entry point: HUD button, keyboard, or menu."""
130 if action == "new_game":
131 self.table.action_new_game()
132 elif action == "undo":
133 self.table.action_undo()
134 elif action == "save":
135 save_game(self.table.state)
136 elif action == "load":
137 loaded = load_game()
138 if loaded is not None:
139 self.table.load_state(loaded)
140 elif action == "quit":
141 self.app.quit()
142
143 def _on_state_changed(self) -> None:
144 st = self.table.state
145 self.hud.set_state(st.score, st.moves, st.is_won)
146
147 def _on_won(self) -> None:
148 self.hud.set_state(self.table.state.score, self.table.state.moves, True)
149
150
151def main() -> None:
152 headless = "--test" in sys.argv
153 title = "Klondike Solitaire (SimVX)"
154 if headless:
155 from simvx.graphics import save_png
156
157 capture_at = [30, 60, 120]
158 app = App(width=DESIGN_W, height=DESIGN_H, title=title, visible=False)
159 frames = app.run_headless(SolitaireRoot(), frames=130, capture_frames=capture_at)
160 out_dir = _PORT_DIR / "screenshots"
161 out_dir.mkdir(exist_ok=True)
162 for idx, img in zip(capture_at, frames, strict=False):
163 out_path = out_dir / f"frame_{idx}.png"
164 save_png(img, out_path)
165 print(f"saved {out_path}")
166 else:
167 app = App(width=DESIGN_W, height=DESIGN_H, title=title)
168 app.run(SolitaireRoot())
169
170
171if __name__ == "__main__":
172 main()