Balatro Feel¶
A card hand with hover lift, drag-to-reorder, and punchy selection juice.
▶ Run in browserUpstream: 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.
Cardowns the interaction state and emits a signal per change;CardVisualconnects 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.pyrasterises each face into an RGBAuint8array (rounded corners, freetype rank glyphs, vector suit pips, soft drop shadow) and hands it straight toSprite2D. No asset files ship with the port.z_indexdraw 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_resizedsignal; 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 |
|---|---|---|
Balatro Feel: A card hand with hover lift, drag-to-reorder, and punchy selection juice. |
280 |
|
Scripted-input harness: exercises the menu, hover, select, drag, and play. |
129 |
|
0 |
||
Card node: interactive root + smoothly-following visual. |
401 |
|
Procedural card-face textures. |
306 |
|
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()