Files
simvx/examples/ports/solitaire

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.