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¶
# 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.
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
├── 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 |
|---|---|---|
Balatro Feel: A card hand with hover lift, drag-to-reorder, and punchy selection juice. |
291 |
|
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. |
333 |
|
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()