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

# the export runs in a copy made by `simvx examples copy ports_balatro_feel`
simvx examples run ports_balatro_feel            # interactive
simvx examples run ports_balatro_feel --test     # headless capture (3 frames)
simvx export web main.py \
    -o 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
├── 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.

280

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.

306

nodes/hand_holder.py

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

248

Source

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