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¶
Run it with the example library installed; the export runs in a copy made by simvx examples copy ports_solitaire:
# Interactive
simvx examples run ports_solitaire
# Headless capture (frames 30/60/120 -> screenshots/)
simvx examples run ports_solitaire --test
# Web export
simvx export web main.py -o 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 Mouse.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
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. |
168 |
|
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. |
358 |
|
Pure-logic Klondike game state. |
356 |
|
Bottom controls strip, scoreboard, and win banner. |
85 |
|
Title menu shown before the first deal. |
87 |
|
JSON save/load for the live GameState. |
72 |
|
TableNode – the playing surface. |
434 |
Source¶
1# /// simvx
2# tags = ["port", "tier-1"]
3# upstream = "https://github.com/zaccnz/solitaire"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Klondike Solitaire: drag-drop cards with springy motion, undo, and save/load.
7
8A clean-room SimVX re-implementation of Klondike, inspired by zaccnz/solitaire
9(C/raylib). It shows procedural ndarray card faces handed straight to Sprite2D,
10a pure-Python GameState mirrored onto a stable scene tree (one CardNode per
11card, never reparented), spring-follow motion with velocity tilt, drag-drop and
12tap-to-auto-move input, full undo history, standard Klondike scoring, atomic
13JSON saves, and a title menu plus bottom controls strip built from
14``simvx.core.ui`` widgets so the whole interface follows a window resize.
15
16Run:
17 simvx examples run ports_solitaire # interactive
18 simvx examples run ports_solitaire --test # headless capture
19
20Web export:
21 simvx export web main.py -o solitaire.html
22"""
23
24from __future__ import annotations
25
26import sys
27from pathlib import Path
28
29_PORT_DIR = Path(__file__).parent
30
31from nodes.hud import Hud # noqa: E402
32from nodes.menu import TitleMenu # noqa: E402
33from nodes.save_io import has_save, load_game, save_game # noqa: E402
34from nodes.table import DESIGN_H, DESIGN_W, TableNode # noqa: E402
35
36from simvx.core import Node2D # noqa: E402
37from simvx.core.input.controls import Key
38from simvx.core.input.state import Input # noqa: E402
39from simvx.graphics import App # noqa: E402
40
41# Keyboard shortcuts. Declared on the root so the scene tree registers them on
42# every root swap, including on the web (where ``main()`` never runs).
43ACTIONS = {
44 "undo": [Key.U, Key.Z],
45 "new_game": [Key.N],
46 "save": [Key.S],
47 "load": [Key.L],
48}
49
50
51class SolitaireRoot(Node2D):
52 """Root scene: green felt background, table, title menu, and bottom HUD."""
53
54 input_context = ACTIONS
55
56 def on_ready(self) -> None:
57 self._queued_entry: str | None = None
58 self.table = TableNode()
59 self.add_child(self.table)
60 self.table.state_changed.connect(self._on_state_changed)
61 self.table.won.connect(self._on_won)
62
63 self.hud = Hud()
64 self.add_child(self.hud)
65 self.hud.button_pressed.connect(self.run_action)
66 self.hud.visible = False
67
68 # Menu first: a save is only restored if the player asks for it.
69 self.menu = self.add_child(
70 TitleMenu(
71 on_new_game=lambda: self._queue_entry("new_game"),
72 on_continue=(lambda: self._queue_entry("load")) if has_save() else None,
73 on_quit=lambda: self._queue_entry("quit"),
74 )
75 )
76 self.table.interactive = False
77
78 self.tree.screen_resized.connect(self._on_screen_resized)
79 self._on_screen_resized(self.tree.screen_size)
80 self._on_state_changed()
81
82 # -------------------------------------------------------------- layout
83 def _on_screen_resized(self, size) -> None:
84 """Refit the table. The felt is drawn from the screen size and the HUD and menu are anchored."""
85 self.table.fit_viewport(size)
86
87 def on_draw(self, renderer) -> None:
88 width, height = self.tree.screen_size
89 # Green felt, with a darker band behind the stock/waste/foundation row.
90 renderer.rect((0, 0), (width, height), fill=(0.10, 0.36, 0.20, 1.0))
91 renderer.rect((0, 0), (width, 80), fill=(0.07, 0.28, 0.16, 1.0))
92
93 # -------------------------------------------------------------- glue
94 def on_update(self, dt: float) -> None:
95 if self._queued_entry is not None:
96 entry, self._queued_entry = self._queued_entry, None
97 self._enter_game(entry)
98 return
99 if self.menu.visible:
100 return
101 for action in ACTIONS:
102 if Input.just_pressed(action):
103 self.run_action(action)
104 # The controls strip sits on top of the table, so a click there must not
105 # also grab a card underneath it.
106 self.table.interactive = not self.hud.covers(Input.mouse_position)
107
108 def _queue_entry(self, entry: str) -> None:
109 """Menu buttons fire during input dispatch; act on the next update."""
110 self._queued_entry = entry
111
112 def _enter_game(self, entry: str) -> None:
113 if entry == "quit":
114 self.app.quit()
115 return
116 if entry == "load":
117 self.run_action("load")
118 else:
119 self.table.action_new_game()
120 self.menu.visible = False
121 self.hud.visible = True
122 self.table.interactive = True
123
124 def run_action(self, action: str) -> None:
125 """One handler for every entry point: HUD button, keyboard, or menu."""
126 if action == "new_game":
127 self.table.action_new_game()
128 elif action == "undo":
129 self.table.action_undo()
130 elif action == "save":
131 save_game(self.table.state)
132 elif action == "load":
133 loaded = load_game()
134 if loaded is not None:
135 self.table.load_state(loaded)
136 elif action == "quit":
137 self.app.quit()
138
139 def _on_state_changed(self) -> None:
140 st = self.table.state
141 self.hud.set_state(st.score, st.moves, st.is_won)
142
143 def _on_won(self) -> None:
144 self.hud.set_state(self.table.state.score, self.table.state.moves, True)
145
146
147def main() -> None:
148 headless = "--test" in sys.argv
149 title = "Klondike Solitaire (SimVX)"
150 if headless:
151 from simvx.graphics import save_png
152
153 capture_at = [30, 60, 120]
154 app = App(width=DESIGN_W, height=DESIGN_H, title=title, visible=False)
155 frames = app.run_headless(SolitaireRoot(), frames=130, capture_frames=capture_at)
156 out_dir = _PORT_DIR / "screenshots"
157 out_dir.mkdir(exist_ok=True)
158 for idx, img in zip(capture_at, frames, strict=False):
159 out_path = out_dir / f"frame_{idx}.png"
160 save_png(img, out_path)
161 print(f"saved {out_path}")
162 else:
163 app = App(width=DESIGN_W, height=DESIGN_H, title=title)
164 app.run(SolitaireRoot())
165
166
167if __name__ == "__main__":
168 main()