Klondike Solitaire

Full Klondike with drag-drop spring/tilt, undo, save/load.

▶ Run in browser

Upstream: 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 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. The springy follow + per-frame target update mirrors Balatro-Feel’s approach.

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

  • Polled input. Per Balatro-Feel’s NOTES, @on_input decorators don’t fire from InputSimulator – the polling path (Input.is_mouse_button_*) works in both real-platform and headless harness modes. So the full input pipeline lives in TableNode.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 by absolute_z_index so this is sufficient – no manual reparenting needed.

  • JSON save instead of SaveManager. The engine’s SaveManager walks Property(persist=True) descriptors. The game’s deck order, move history, and per-card face_up state aren’t naturally Property values – they live in mutable Python lists. Easier and faster to serialise the live state to JSON via GameState.to_dict().

Visual reference

Idle headless captures (--test):

File

Stage

screenshots/frame_30.png

Initial deal – cards still settling

screenshots/frame_60.png

Cards settled, full tableau visible

screenshots/frame_120.png

Static idle scene

Scripted harness (harness.py):

File

Stage

01_deal.png

Initial deal complete

02_drawn.png

3 cards drawn from stock onto waste

03_drag.png

Mid-drag of waste card toward foundation, with tilt

04_drop.png

Drop attempt resolved (springs back if illegal)

05_undo.png

After pressing ‘U’ – one stock-cycle undone

06_nearwin.png

Synthesised end-game: 4 Kings on foundations + K♣ on waste – one click from win

07_won.png

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