Klondike Solitaire

drag-drop cards with springy motion, undo, and save/load.

▶ Run in browser

Upstream: 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 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.

  • 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, and every entry point that swaps it goes through TableNode.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_update reads the pointer in the same pass that writes the card targets. The menu and the controls strip are simvx.core.ui widgets 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_viewport scales 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 by absolute_z_index, so no reparenting is needed.

  • JSON save instead of SaveManager. The engine’s SaveManager walks Property(persist=True) descriptors. The deck order, move history, and per-card face_up state are not naturally Property values: they live in mutable Python lists. Serialising GameState.to_dict() to JSON is simpler and keeps the save file readable. The crash-safe write itself is the engine’s simvx.core.io.atomic_write_text.

Source files

File

Summary

Lines

main.py

Klondike Solitaire: drag-drop cards with springy motion, undo, and save/load.

172

harness.py

Scripted-input harness: exercises the menu, deal, click-to-draw, drag-drop, undo, and win.

165

nodes/card_node.py

CardNode – visual representation of a single card.

150

nodes/card_textures.py

Procedural card textures for Klondike Solitaire.

378

nodes/game_state.py

Pure-logic Klondike game state.

356

nodes/hud.py

Bottom controls strip, scoreboard, and win banner.

94

nodes/menu.py

Title menu shown before the first deal.

95

nodes/save_io.py

JSON save/load for the live GameState.

72

nodes/table.py

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()