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