Balatro Feel

A card hand with hover lift, drag-to-reorder, and punchy selection juice.

▶ Run in browser

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

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

Balatro Feel (SimVX port)

SimVX port of mixandjam/balatro-feel, André Cardoso’s Unity study of what makes Balatro’s card hand feel so good. Seven procedurally drawn cards fan across the table; each lifts and tilts toward the cursor on hover, punches when selected, and swaps slots with its neighbours while dragged. Playing a hand pulses the selected cards left to right before clearing them.

There is no scoring and no deck: the whole point is the feel.

Licensing: clean re-implementation under MIT, with every card face, suit pip, and drop shadow generated at runtime as a NumPy array. No upstream art, audio, or code is redistributed. See ATTRIBUTION.md and LICENSE.

Run

# from the repo root
uv run python examples/ports/balatro_feel/main.py            # interactive
uv run python examples/ports/balatro_feel/main.py --test     # headless capture (3 frames)
uv run python examples/ports/balatro_feel/harness.py         # scripted-input capture (7 stages)
uv run simvx export web examples/ports/balatro_feel/main.py \
    -o /tmp/balatro_feel.html

Controls

Every action has an on-screen button, so mouse or touch alone is enough; the keys are shortcuts for the same verbs.

Input

Action

Hover

Lift, scale-up, parallax tilt toward the cursor

Click

Toggle selection (selected cards rest higher)

Drag

Card follows the cursor; crossing a neighbour swaps their slots

Play hand / Space

Pulse the selected cards left to right, then deselect

Discard / Delete

Remove the hovered card, or every selected card

Clear / right-click

Deselect all

New hand

Re-deal the seven cards

Menu / Esc

Back to the menu (Esc quits from the menu)

What it demonstrates

  • Signals as the seam between logic and juice. Card owns the interaction state and emits a signal per change; CardVisual connects to those signals in its constructor and answers each one with a spring, a punch, or a scale. Neither node calls the other.

  • Coroutines for animation. Each punch is a damped-sine coroutine started on the event and finishing on its own, with no tween graph or update-flag bookkeeping.

  • InputMap actions polled from on_update, so one code path serves the window, a touch screen, and the scripted harness.

  • Procedural textures. nodes/card_textures.py rasterises each face into an RGBA uint8 array (rounded corners, freetype rank glyphs, vector suit pips, soft drop shadow) and hands it straight to Sprite2D. No asset files ship with the port.

  • z_index draw order, so a hovered, selected, or dragged card lifts above its neighbours while resting cards keep slot order.

  • Resize-aware layout. The hand centre and every text box are recomputed from the tree’s screen_resized signal; the controls strip is anchored, so the port follows a desktop window resize and the responsive web canvas alike.

Layout

balatro_feel/
├── main.py                # entry, InputMap actions, menu + table screens, controls strip
├── harness.py             # scripted input via InputSimulator + staged screenshots
├── nodes/
│   ├── card.py            # Card (state + signals) and CardVisual (spring-follow juice)
│   ├── card_textures.py   # procedural card faces, suit pips, drop shadow
│   └── hand_holder.py     # fan layout, slot swapping, input dispatch, hand verbs
├── screenshots/           # idle + harness captures
├── ATTRIBUTION.md
└── LICENSE

License

MIT (matches upstream).

Source files

File

Summary

Lines

main.py

Balatro Feel: A card hand with hover lift, drag-to-reorder, and punchy selection juice.

291

harness.py

Scripted-input harness: exercises the menu, hover, select, drag, and play.

129

nodes/__init__.py

0

nodes/card.py

Card node: interactive root + smoothly-following visual.

401

nodes/card_textures.py

Procedural card-face textures.

333

nodes/hand_holder.py

HorizontalHandHolder: owns slots, dispatches input, animates the fan curve.

252

Source

  1"""Balatro Feel: A card hand with hover lift, drag-to-reorder, and punchy selection juice.
  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
  9Re-implements the mixandjam "Balatro-feel" Unity demo in pure SimVX. Seven procedurally
 10drawn cards fan across the table; each lifts and tilts toward the cursor on hover, punches
 11when selected, and swaps slots with its neighbours while dragged. Playing a hand pulses
 12the selected cards left to right before clearing them.
 13
 14Demonstrates:
 15    * A logic node (Card) driving a spring-following visual (CardVisual): events arrive
 16      as Signals, so the card never has to know a visual exists.
 17    * Coroutines for damped-sine punch animation: started per event, self-terminating.
 18    * InputMap actions polled from on_update, so one code path serves the window, a
 19      touch screen, and the scripted harness.
 20    * Procedural RGBA NumPy textures handed straight to Sprite2D, with no asset files.
 21    * z_index draw-order control, so the active card lifts above its neighbours.
 22    * Engine UI Controls anchored into a resize-aware bottom controls strip.
 23
 24Controls (every action also has an on-screen button, so mouse or touch alone is enough):
 25    Move over a card      lift, scale-up, parallax tilt toward the cursor
 26    Click a card          toggle selection
 27    Drag a card           reorder the hand; slots swap as it crosses a neighbour
 28    Right-click / Clear   deselect every card
 29    Space / Play hand     pulse the selected cards in order, then deselect them
 30    Delete / Discard      remove the hovered card, or the selected ones
 31    Esc                   back to the menu (quits from the menu)
 32
 33Run:
 34    uv run python examples/ports/balatro_feel/main.py
 35    uv run python examples/ports/balatro_feel/main.py --test    # headless capture
 36    uv run simvx export web examples/ports/balatro_feel/main.py -o /tmp/balatro_feel.html
 37"""
 38
 39from __future__ import annotations
 40
 41import sys
 42from collections.abc import Callable
 43from pathlib import Path
 44
 45# Allow running from any cwd
 46_PORT_DIR = Path(__file__).resolve().parent
 47if str(_PORT_DIR) not in sys.path:
 48    sys.path.insert(0, str(_PORT_DIR))
 49
 50from nodes.card_textures import make_default_hand  # noqa: E402
 51from nodes.hand_holder import HorizontalHandHolder  # noqa: E402
 52
 53from simvx.core import Input, InputMap, Key, MouseButton, Node2D, Text2D, Vec2  # noqa: E402
 54from simvx.core.ui.containers import HBoxContainer  # noqa: E402
 55from simvx.core.ui.enums import AnchorPreset  # noqa: E402
 56from simvx.core.ui.widgets import Button, Panel  # noqa: E402
 57from simvx.graphics import App  # noqa: E402
 58
 59WIDTH = 1280
 60HEIGHT = 720
 61
 62# Bottom controls strip
 63STRIP_HEIGHT = 72
 64BUTTON_W = 148
 65BUTTON_H = 40
 66BUTTON_GAP = 12
 67STRIP_COLOUR = (0.86, 0.86, 0.88, 1.0)
 68TABLE_COLOUR = (0.06, 0.15, 0.12, 1.0)
 69
 70
 71class MenuScreen(Node2D):
 72    """Landing screen: title, house rules, and a Play button reachable by mouse or touch."""
 73
 74    def __init__(self, on_start: Callable[[], None]) -> None:
 75        super().__init__(name="Menu")
 76        self._on_start = on_start
 77
 78    def on_ready(self) -> None:
 79        # A non-interactive hand peeks over the bottom edge as a backdrop: same
 80        # nodes as the table, just never reading input.
 81        self.backdrop = self.add_child(
 82            HorizontalHandHolder(
 83                cards=make_default_hand(),
 84                centre=Vec2(WIDTH * 0.5, HEIGHT),
 85                interactive=False,
 86            )
 87        )
 88        self.title = self.add_child(
 89            Text2D(
 90                text="Balatro Feel",
 91                font_scale=3.4,
 92                align="centre",
 93                colour=(1.0, 0.94, 0.80, 1.0),
 94            )
 95        )
 96        self.subtitle = self.add_child(
 97            Text2D(
 98                text="A SimVX port of the mixandjam card-feel study",
 99                font_scale=1.2,
100                align="centre",
101                colour=(0.82, 0.82, 0.88, 1.0),
102                fit_to_width=True,
103            )
104        )
105        self.rules = [
106            self.add_child(
107                Text2D(text=line, font_scale=1.0, align="centre", colour=(0.90, 0.90, 0.93, 1.0), fit_to_width=True)
108            )
109            for line in (
110                "Hover a card to lift it, click to select, drag to reorder the hand.",
111                "Play the selected cards for a staged pulse, or discard them outright.",
112                "Keys mirror the buttons: Space plays, Delete discards, right-click clears.",
113            )
114        ]
115
116        self.play_button = Button("Play", on_press=self._on_start)
117        self.play_button.font_size = 22.0
118        self.play_button.set_anchor_preset(AnchorPreset.CENTER)
119        self.play_button.margin_left = -110
120        self.play_button.margin_right = 110
121        self.play_button.margin_top = 96
122        self.play_button.margin_bottom = 150
123        self.add_child(self.play_button)
124
125        self._layout(self.tree.screen_size)
126        self.tree.screen_resized.connect(self._layout)
127
128    def on_exit_tree(self) -> None:
129        self.tree.screen_resized.disconnect(self._layout)
130
131    def _layout(self, size: tuple[float, float]) -> None:
132        """Re-centre the copy for the live viewport (desktop resize and responsive web)."""
133        w, h = size
134        self.title.rect = (0, h * 0.16, w, 60)
135        self.subtitle.rect = (0, h * 0.16 + 76, w, 28)
136        for idx, line in enumerate(self.rules):
137            line.rect = (w * 0.1, h * 0.16 + 132 + idx * 30, w * 0.8, 26)
138        self.backdrop.centre = Vec2(w * 0.5, h * 1.02)
139        # Anchored Controls resolve their rect from the viewport, which the resize
140        # changed without touching any of their own properties: repaint explicitly.
141        self.play_button.queue_redraw()
142
143
144class CardTable(Node2D):
145    """The playable hand plus its heading, key hint, and bottom controls strip."""
146
147    def __init__(self, on_menu: Callable[[], None]) -> None:
148        super().__init__(name="CardTable")
149        self._on_menu = on_menu
150
151    def on_ready(self) -> None:
152        self.hand = self.add_child(
153            HorizontalHandHolder(
154                cards=make_default_hand(),
155                centre=Vec2(WIDTH * 0.5, HEIGHT * 0.5),
156            )
157        )
158        self.heading = self.add_child(
159            Text2D(
160                text="Balatro Feel",
161                font_scale=1.6,
162                align="centre",
163                colour=(1.0, 0.94, 0.80, 1.0),
164            )
165        )
166        self.hint = self.add_child(
167            Text2D(
168                text="Hover to lift  ·  click to select  ·  drag to reorder  ·  "
169                "Space plays  ·  Delete discards  ·  right-click clears",
170                font_scale=0.85,
171                align="centre",
172                colour=(0.75, 0.75, 0.80, 1.0),
173                fit_to_width=True,
174            )
175        )
176
177        self.strip = self.add_child(Panel())
178        self.strip.bg_colour = STRIP_COLOUR
179        self.strip.set_anchor_preset(AnchorPreset.BOTTOM_WIDE)
180        self.strip.margin_top = -STRIP_HEIGHT
181        self.strip.margin_bottom = 0
182
183        # Buttons live in an HBox so the container owns their positions; the box
184        # itself is a top-level Control, anchored bottom-centre with margins.
185        self.buttons = self.add_child(HBoxContainer())
186        self.buttons.separation = BUTTON_GAP
187        self.buttons.alignment = "center"
188        actions: tuple[tuple[str, Callable[[], None]], ...] = (
189            ("Play hand", self.hand.play_selected),
190            ("Discard", self.hand.discard),
191            ("Clear", self.hand.clear_selection),
192            ("New hand", self._new_hand),
193            ("Menu", self._on_menu),
194        )
195        for label, handler in actions:
196            button = Button(label, on_press=handler)
197            button.size = (BUTTON_W, BUTTON_H)
198            button.font_size = 16.0
199            self.buttons.add_child(button)
200        total_w = len(actions) * BUTTON_W + (len(actions) - 1) * BUTTON_GAP
201        self.buttons.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
202        self.buttons.margin_left = -total_w * 0.5
203        self.buttons.margin_right = total_w * 0.5
204        self.buttons.margin_top = -(STRIP_HEIGHT + BUTTON_H) * 0.5
205        self.buttons.margin_bottom = self.buttons.margin_top + BUTTON_H
206
207        self._layout(self.tree.screen_size)
208        self.tree.screen_resized.connect(self._layout)
209
210    def on_exit_tree(self) -> None:
211        self.tree.screen_resized.disconnect(self._layout)
212
213    def _new_hand(self) -> None:
214        self.hand.deal(make_default_hand())
215
216    def _layout(self, size: tuple[float, float]) -> None:
217        """Recompute hand centre and text boxes from the live viewport size."""
218        w, h = size
219        table_h = max(320.0, h - STRIP_HEIGHT)
220        self.hand.centre = Vec2(w * 0.5, table_h * 0.56)
221        # Cards are hit-tested by the holder, not by the UI layer, so it needs to
222        # know where the strip starts to stop treating strip clicks as card clicks.
223        self.hand.input_clip_bottom = h - STRIP_HEIGHT
224        self.heading.rect = (0, 34, w, 34)
225        self.hint.rect = (w * 0.05, h - STRIP_HEIGHT - 34, w * 0.9, 24)
226        # Anchored Controls resolve their rect from the viewport, which the resize
227        # changed without touching any of their own properties: repaint explicitly.
228        for control in (self.strip, self.buttons, *self.buttons.children):
229            control.queue_redraw()
230
231
232class BalatroFeelRoot(Node2D):
233    """Root scene: swaps between the menu and the card table."""
234
235    def on_ready(self) -> None:
236        # Input actions belong to the root's ready path: the web export never calls main().
237        InputMap.add_action("primary", [MouseButton.LEFT])
238        InputMap.add_action("clear_selection", [MouseButton.RIGHT])
239        InputMap.add_action("play_hand", [Key.SPACE])
240        InputMap.add_action("discard_card", [Key.DELETE])
241        InputMap.add_action("back", [Key.ESCAPE])
242
243        self.screen: Node2D | None = None
244        self.show_menu()
245
246    def _swap_screen(self, screen: Node2D) -> None:
247        if self.screen is not None:
248            self.remove_child(self.screen)
249        self.screen = self.add_child(screen)
250
251    def show_menu(self) -> None:
252        self._swap_screen(MenuScreen(on_start=self.show_table))
253
254    def show_table(self) -> None:
255        self._swap_screen(CardTable(on_menu=self.show_menu))
256
257    def on_update(self, dt: float) -> None:
258        if Input.is_action_just_pressed("back"):
259            if isinstance(self.screen, CardTable):
260                self.show_menu()
261            else:
262                self.app.quit()
263
264    def on_draw(self, renderer) -> None:
265        # Felt backdrop behind every screen. Oversized so a resized window stays
266        # covered without redrawing; the colour never changes, so one capture is enough.
267        renderer.draw_rect((0, 0), (WIDTH * 4, HEIGHT * 4), colour=TABLE_COLOUR, filled=True)
268
269
270def main() -> None:
271    headless = "--test" in sys.argv
272    if headless:
273        from simvx.graphics import save_png
274
275        capture_at = [30, 60, 119]
276        app = App(width=WIDTH, height=HEIGHT, title="Balatro Feel (SimVX)", visible=False)
277        frames = app.run_headless(BalatroFeelRoot(), frames=120, capture_frames=capture_at)
278        out_dir = _PORT_DIR / "screenshots"
279        out_dir.mkdir(exist_ok=True)
280        for idx, img in zip(capture_at, frames, strict=False):
281            out_path = out_dir / f"frame_{idx}.png"
282            save_png(img, out_path)
283            print(f"saved {out_path}")
284        app.quit()
285    else:
286        app = App(width=WIDTH, height=HEIGHT, title="Balatro Feel (SimVX)")
287        app.run(BalatroFeelRoot())
288
289
290if __name__ == "__main__":
291    main()