Play it in your browser · Source on GitHub
I wanted a card system that feels good to touch before any game rules exist. So this one has no rules: a standard 52-card deck, a hand, slots on the table and a discard pile.
- A curved hand of up to 8 cards.
- A draw pile and a discard pile.
- Slots: dock a card, swap it with another, or pull it back out.
- Drag to reorder the hand.
Cards don't handle input, a manager does#
Cards, slots and piles only emit signals (card_hovered, drag_started, …). A single CardManager listens to all of them and decides what happens. That matters when cards overlap: several Area2Ds can be under the cursor at once, so the manager picks the one with the highest z_index and, on a tie, the latest in the tree. Without it, hover flickers between cards.
The curve is three formulas#
For a hand of n cards, with u the card's offset from the center and norm = u / half_span:
var spacing = minf(max_card_spacing, max_hand_width / (n - 1))
var pos_x = hand_center.x + u * spacing
var pos_y = hand_center.y + norm * norm * arc_height # parabola
var angle = minf(card_angle_step_deg, max_angle_spread_deg / (n - 1))
var rot = deg_to_rad(u * angle)
Spacing and angle both compress as the hand grows, so 8 cards fan tighter instead of running off screen. Every constant is an @export, so tuning the feel never touches the logic.
Reordering without jitter#
While you drag, the card is live-inserted into the hand and the others slide aside. The naive version flickers: when the card sits on the boundary between two slots, the insertion index flips every frame.
The fix is a deadzone. get_insertion_index_for_position shifts the thresholds around the card's current index by 20 px, so you have to move clearly past the boundary before the order changes.

Tilt from velocity, perspective from a shader#
Two separate effects:
- Drag tilt: horizontal mouse velocity becomes a rotation, clamped to 25°, and settles with
1 - exp(-k·dt). That form is frame-rate independent, unlike a plainlerp(a, b, 0.1). - Hover tilt:
card_tilt.gdshaderfakes a 3D rotation on a 2D sprite. It remaps UVs with a perspective divide (denom = cos·cam_dist + p.y·sin), so the card leans away from the camera with no 3D node.
Sound does half the work#
CardAudio keeps a pool of 12 voices with priorities and steals the lowest-priority one when it runs out, so a fast shuffle never cuts off a drop or a discard. Every sound gets random pitch variation, and the discard is pitched down (0.88–0.94) so it lands heavier than a normal placement. Same sample, different feel.

Try it#
Open the playable (desktop recommended), or read the source.
