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

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

168

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.

358

nodes/game_state.py

Pure-logic Klondike game state.

356

nodes/hud.py

Bottom controls strip, scoreboard, and win banner.

85

nodes/menu.py

Title menu shown before the first deal.

87

nodes/save_io.py

JSON save/load for the live GameState.

72

nodes/table.py

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