Balatro-Feel

Card hover / select / drag / play, pure tween expressiveness.

▶ Run in browser

Upstream: https://github.com/mixandjam/balatro-feel

Tags: port tier-1

Balatro-Feel: SimVX port

Recreates the mixandjam Balatro-feel Unity demo in pure SimVX. A hand of seven cards with hover scale, follow-rotation, drag-to-reorder, selection lift, and a Space-key “play hand” punch sequence.

Run

From the SimVX repo root:

# Interactive
uv run python ported_games/balatro_feel/simvx_port/main.py

# Headless capture (frames 30, 60, 119 → screenshots/frame_*.png)
uv run python ported_games/balatro_feel/simvx_port/main.py --test

# Scripted-input harness (six staged screenshots demonstrating hover/select/drag/play)
uv run python ported_games/balatro_feel/simvx_port/harness.py

# Web export
uv run simvx export web ported_games/balatro_feel/simvx_port/main.py \
    -o ported_games/balatro_feel/simvx_port/web/index.html

Controls

Input

Action

Hover

Lift, scale-up, parallax tilt toward cursor

Click

Toggle selection (selected cards rest higher)

Drag

Card follows cursor; crosses swap slots in the hand

Right-click

Deselect all

Space

Punch-pulse the selected cards in left-to-right order, then deselect

Delete

Remove the hovered card

Layout

simvx_port/
├── main.py                # entry + InputMap + root scene
├── harness.py             # scripted input + screenshot capture
├── pyproject.toml         # [tool.simvx] root = "BalatroFeelRoot"
├── nodes/
│   ├── card_textures.py   # procedural card faces, suit pips, drop shadow
│   ├── card.py            # Card (input root) + CardVisual (spring-follow visual)
│   └── hand_holder.py     # hand layout, slot swap, input dispatch
├── screenshots/           # idle + harness captures
└── web/                   # exported HTML bundle

See ../NOTES.md for design decisions, deviations from upstream, and engine friction.

License

MIT (matches upstream).

Source

 1"""Balatro-Feel: Card hover / select / drag / play, pure tween expressiveness.
 2
 3# /// simvx
 4# tags = ["port", "tier-1"]
 5# upstream = "https://github.com/mixandjam/balatro-feel"
 6# web = { width = 1280, height = 720, responsive = true }
 7# ///
 8
 9Run:
10    uv run python ported_games/balatro_feel/simvx_port/main.py
11    uv run python ported_games/balatro_feel/simvx_port/main.py --test    # headless capture
12"""
13
14from __future__ import annotations
15
16import sys
17from pathlib import Path
18
19# Allow running from any cwd
20_PORT_DIR = Path(__file__).parent
21if str(_PORT_DIR) not in sys.path:
22    sys.path.insert(0, str(_PORT_DIR))
23
24from nodes.card_textures import make_default_hand  # noqa: E402
25from nodes.hand_holder import HorizontalHandHolder  # noqa: E402
26
27from simvx.core import Node2D, Text2D  # 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 BalatroFeelRoot(Node2D):
38    """Root scene: hand of 7 cards centred low on the screen."""
39
40    def on_ready(self) -> None:
41        # Input map (must live in root.on_ready for web export)
42        InputMap.add_action("delete_card", [Key.DELETE])
43        InputMap.add_action("play_hand", [Key.SPACE])
44        InputMap.add_action("primary", [MouseButton.LEFT])
45        InputMap.add_action("secondary", [MouseButton.RIGHT])
46
47        cards = make_default_hand()
48        self.hand = HorizontalHandHolder(
49            cards=cards,
50            centre=Vec2(WIDTH / 2, HEIGHT * 0.55),
51        )
52        self.add_child(self.hand)
53
54        # Title + instructions
55        self.add_child(Text2D(
56            text="Balatro-Feel  \u00b7  SimVX port",
57            x=WIDTH / 2 - 200, y=64,
58            font_scale=1.6,
59        ))
60        instructions = (
61            "Hover cards \u2022 Click to select \u2022 Drag to reorder "
62            "\u2022 Right-click clears \u2022 Space plays \u2022 Delete removes"
63        )
64        self.add_child(Text2D(
65            text=instructions,
66            x=WIDTH / 2 - 460, y=110,
67            font_scale=0.85,
68            colour=(0.75, 0.75, 0.78, 1.0),
69        ))
70
71
72def main() -> None:
73    headless = "--test" in sys.argv
74    if headless:
75        from simvx.graphics import save_png
76
77        capture_at = [30, 60, 119]
78        app = App(width=WIDTH, height=HEIGHT, title="Balatro-Feel (SimVX)", visible=False)
79        frames = app.run_headless(BalatroFeelRoot(), frames=120, capture_frames=capture_at)
80        out_dir = _PORT_DIR / "screenshots"
81        out_dir.mkdir(exist_ok=True)
82        for idx, img in zip(capture_at, frames, strict=False):
83            out_path = out_dir / f"frame_{idx}.png"
84            save_png(out_path, img)
85            print(f"saved {out_path}")
86    else:
87        app = App(width=WIDTH, height=HEIGHT, title="Balatro-Feel (SimVX)")
88        app.run(BalatroFeelRoot())
89
90
91if __name__ == "__main__":
92    main()