gamedev.
gamedev5 min read

LÖVE Card Game Animations with flux Tweens

Build snappy card draw, hover, and play animations in LÖVE using rxi's flux tween library, with chainable easings and frame-perfect timing.

Smooth Card Animations in LÖVE with flux

Card games live or die by feel. A 0.25s tween from deck to hand is the difference between "cards appear" and "cards arrive". Most LÖVE devs reach for raw lerp loops first, hit the chaining wall on the second feature, then go looking for a tween library. This walks through using rxi's flux to handle the four animations every card game actually needs: draw, reflow, hover, play.

Why flux over the alternatives

Three options dominate in the LÖVE ecosystem:

  • flux (rxi) – about 150 lines of pure Lua, no dependencies, chainable
  • hump.timer.tween – part of vrld's hump suite, callback-based
  • hand-rolled lerps living inside love.update

Flux wins on two specific axes for card games. First, chaining. Card sequences (hand to board, flip, settle) are linear, and flux's :after() reads top-to-bottom, so a four-step animation stays under ten lines without callback pyramids. Second, group control. Flux groups let you pause, iterate, or clear every card tween during a shake or shuffle without tracking individual handles.

If you only need one tween at a time and never chain, hump.timer is fine. Push past 30 cards in flight with chained sequences and flux saves you debugger sessions.

Setup

Drop flux.lua into your_project/lib/ and require it in main.lua. Update flux every frame:

local flux = require "lib.flux"

function love.update(dt)
  flux.update(dt)
end

That's the full integration. Flux holds an internal list of active tweens, walks them on update, and removes finished ones automatically.

The four animations every card game needs

Each Card is a table with x, y, rot, and scale fields. Flux mutates those fields in place across frames. The draw call reads them and the renderer never has to know a tween exists.

1. Deal: deck to hand

function dealCard(card, slotX, slotY)
  card.x, card.y = DECK_X, DECK_Y
  card.scale = 0.6
  card.rot = -0.4

  flux.to(card, 0.35, { x = slotX, y = slotY, scale = 1.0, rot = 0 })
    :ease("backout")
    :oncomplete(function() card.dealt = true end)
end

backout overshoots slightly, then settles. A deal duration of 0.30–0.40s reads as "fast but not jittery". Faster than 0.20s and players cannot track which card landed where. Slower than 0.50s and a 7-card opening hand takes 3.5 seconds to deal, which kills pacing on the first turn.

2. Hand reflow when a card leaves

When a card is played or discarded, every remaining card needs to slide into the gap. Fire one tween per card on the same frame and let flux animate them in parallel.

function reflowHand(hand)
  local spacing = math.min(80, (HAND_WIDTH - CARD_W) / math.max(1, #hand - 1))
  for i, card in ipairs(hand) do
    local targetX = HAND_CENTER + (i - (#hand + 1) / 2) * spacing
    flux.to(card, 0.18, { x = targetX, y = HAND_Y })
      :ease("cubicout")
  end
end

cubicout decelerates without overshooting, which fits sliding behavior. 0.18s is short enough that reflow finishes before the player's eye finds the gap and asks "what just happened".

3. Hover lift

Hover wants a quick lift on enter and a quick fall on leave. The trap here is fast back-and-forth hover spawning competing tweens that fight over the card's y. Cache the active tween and stop it before queueing a new one.

function card:onHoverEnter()
  if self.hoverTween then self.hoverTween:stop() end
  self.hoverTween = flux.to(self, 0.12, { y = self.baseY - 24, scale = 1.05 })
    :ease("quadout")
end

function card:onHoverLeave()
  if self.hoverTween then self.hoverTween:stop() end
  self.hoverTween = flux.to(self, 0.10, { y = self.baseY, scale = 1.0 })
    :ease("quadout")
end

Without the :stop() call, jittering on intermediate values is the visible failure mode. Always store any tween that is reachable from input.

4. Play: hand to board, chained

Playing a card breaks down into three beats: lift off, glide to the slot, settle with a tiny rotational kick. Flux's :after() lets the whole sequence stay under one return-style chain.

function playCard(card, slotX, slotY)
  flux.to(card, 0.10, { y = card.y - 30, scale = 1.10 })
    :ease("quadout")
    :after(card, 0.30, { x = slotX, y = slotY, scale = 1.0 })
      :ease("cubicinout")
    :after(card, 0.08, { rot = 0.05 })
      :ease("quadout")
    :after(card, 0.08, { rot = 0 })
      :ease("quadinout")
      :oncomplete(function() onCardSettled(card) end)
end

The full sequence runs 0.56 seconds, roughly 33 frames at 60fps. That is plenty of time for the player to read the move without feeling sluggish. The four-stage version through :after() is the same logical shape as a callback pyramid in raw love.update, but you can read it linearly and reorder beats without rewiring callbacks.

Groups for shuffle and screen shake

A shuffle wants every card animating at once. A screen shake wants every card animation paused while the camera offset effect plays. Flux groups handle both without tracking handles by hand.

local cardGroup = flux.group()

function love.update(dt)
  if not screenShaking then
    cardGroup:update(dt)
  end
  flux.update(dt)
end

function shuffleHand(hand)
  for _, card in ipairs(hand) do
    cardGroup:to(card, 0.20, { rot = math.random() * 0.4 - 0.2 })
      :after(card, 0.20, { rot = 0 })
  end
end

When screenShaking is true, the group freezes but the global flux queue keeps running, so HUD tweens, fade overlays, and damage numbers keep ticking. That separation matters once you start layering effects.

Frame budget and gotchas

A few rough edges bite first-timers:

  • Flux mutates fields by reference. Passing a Lua number into flux.to does nothing because numbers are not assigned by reference. Always pass a table and key paths.
  • Tweening rot past 2 * math.pi does not wrap. If a card needs to spin a full turn and stop level, reset its rotation before each new spin tween.
  • :oncomplete() fires on the same frame the final value lands. Do not queue a new tween on the same field inside the callback without nilling the old handle first.
  • Groups do not auto-update. If you create one, you own its :update(dt).

Performance rarely matters here. Even 100 cards with three concurrent tweens each runs well under a 1ms update budget on commodity hardware, and most card games never put more than 20 cards in flight at the same time. The bottleneck moves to draw calls long before flux becomes the hot path.

References