Skip to content

11. Solitaire

The second game is the patience that came with every desktop of the 1990s: Klondike. Fifty-two cards, seven columns, four foundations to fill from ace to king. Mines taught you to model rules, draw a grid and react to clicks. Solitaire adds what makes a game feel alive: cards you pick up and carry, cards that fly to their places, mistakes you can take back, and at the end the famous cascade of bouncing cards.

Solitaire while playing

A game in progress. Cards, backs and suit symbols are drawn in code.

What you will learn

  • Modelling cards and piles, and keeping the rules apart from where a card is drawn
  • A text notation for game positions that makes tests read like the game
  • Undo and redo with snapshots, and why that beats undoing each move by hand
  • Drawing complex pictures from shapes and transforms: pips, suit symbols, turned indices
  • Animation: easing curves, tweens and an animation timer, and how one idea animates every move
  • Drag and drop with the mouse, including drops in the wrong place
  • Hit testing: which card is under the mouse when cards overlap
  • A physics toy for the victory animation: gravity, bouncing, trails

The complete game is in amade-solitaire-game.

The rules

  1. A shuffled deck is dealt into seven columns: the first gets one card, the seventh seven. Only the top card of each column lies face up.
  2. The other 24 cards form the stock, face down. A click turns one card (or three, in the harder variant) onto the waste. When the stock is empty, the waste is turned over to become the stock again.
  3. The four foundations are built up by suit, from ace to king.
  4. In the columns, cards are built down in alternating colours: a black 7 on a red 8. A face-up run moves together. Only a king may fill an empty column.
  5. When a face-down card in a column is uncovered, it turns face up.
  6. The game is won when all 52 cards lie on the foundations.

Two comforts of computer solitaire complete the list: any move can be undone, and when every card lies face up and the stock is empty, the game finishes itself.

Step 1: Cards, piles and where they lie

A card is a rank and a suit, nothing more. A Java record says exactly that:

public record Card(Rank rank, Suit suit) { … }

Rank and Suit are enums: thirteen ranks with a value() from 1 to 13, four suits, each knowing whether it is red.

Notice what a card does not have: a "face up" flag and a position. Whether a card lies face up is not a property of the card but of where it lies. In a column the bottom cards are face down; on a foundation every card is face up. So the face-up state belongs to the pile. A Pile keeps its cards bottom first and a single number, faceDown(): how many of the lowest cards lie face down.

This decision pays off immediately. Cards are immutable values, so a copy of a game can share them without danger, which will make undo trivial. And "turn the uncovered card over" is one line: lower faceDown by one.

Where a pile lies is described by a PileId: a kind (STOCK, WASTE, FOUNDATION, TABLEAU) and an index. Thirteen piles in all. The rules talk about PileIds; pixel positions are none of their business.

Dealing

amade-solitaire-game · domain/Klondike.java
    /** Shuffles a deck and deals: column {@code i} gets {@code i + 1} cards, the top one face up. */
    public static Klondike deal(boolean drawThree, RandomGenerator random) {
        List<Card> deck = Card.deck();
        Collections.shuffle(deck, random);
        Klondike game = new Klondike(drawThree);
        int next = 0;
        for (int column = 0; column < PileId.COLUMNS; column++) {
            Pile pile = game.pile(PileId.tableau(column));
            for (int i = 0; i <= column; i++) pile.add(deck.get(next++), i == column);
        }
        Pile stock = game.pile(PileId.STOCK);
        while (next < deck.size()) stock.add(deck.get(next++), false);
        return game;
    }

Collections.shuffle mixes the deck with the random generator it is given. As in Mines, the game receives the generator from outside, so a test with a fixed seed gets the same deal every time:

amade-solitaire-game · test/domain/KlondikeTest.java
    @Test
    void theDealUsesEveryCardOnce() {
        Klondike game = Klondike.deal(false, new SplittableRandom(1));
        Set<Card> seen = new HashSet<>();
        for (Pile pile : game.piles().values()) seen.addAll(pile.cards());
        assertEquals(52, seen.size());
        for (int column = 0; column < PileId.COLUMNS; column++) {
            Pile pile = game.pile(PileId.tableau(column));
            assertEquals(column + 1, pile.size());
            assertEquals(column, pile.faceDown(), "only the top card lies face up");
        }
        assertEquals(24, game.pile(PileId.STOCK).size());
    }

Step 2: The rules as code

One method answers the central question of the game: may these cards go there?

amade-solitaire-game · domain/Klondike.java
    /** True if the cards from {@code index} to the top of {@code from} may be put on {@code to}. */
    public boolean canMove(PileId from, int index, PileId to) {
        if (from.equals(to) || !canPick(from, index)) return false;
        Pile source = pile(from);
        Card moving = source.cards().get(index);
        int count = source.size() - index;
        Card target = pile(to).top();
        return switch (to.kind()) {
            case STOCK, WASTE -> false;
            // Foundations: one card at a time, ace first, then the same suit one rank up.
            case FOUNDATION -> count == 1 && (target == null
                    ? moving.rank() == Rank.ACE
                    : moving.suit() == target.suit() && moving.rank().value() == target.rank().value() + 1);
            // Columns: a king on an empty column, else the other colour one rank down.
            case TABLEAU -> target == null
                    ? moving.rank() == Rank.KING
                    : moving.red() != target.red() && moving.rank().value() == target.rank().value() - 1;
        };
    }

Compare it with rules 3 and 4 above: the code says the same in the same order. The switch over the kind of target pile makes it impossible to forget a case; the compiler checks that all four kinds are handled.

canPick (not shown) checks the other half: the cards must lie face up, and from the waste or a foundation only the top card may be taken. With both checks, the move itself is short:

amade-solitaire-game · domain/Klondike.java
    /**
     * Moves the cards from {@code index} to the top of {@code from} onto {@code to} if the rules allow it.
     * A face-down card uncovered in a column is turned over.
     *
     * @return true if the cards moved
     */
    public boolean move(PileId from, int index, PileId to) {
        if (!canMove(from, index, to)) return false;
        Pile target = pile(to);
        for (Card c : pile(from).takeFrom(index)) target.add(c, true);
        pile(from).turnTop();
        moves++;
        return true;
    }

move refuses illegal moves itself (if (!canMove(...)) return false). The model protects its own rules. No caller can break them, however buggy it is.

Drawing from the stock, including turning the waste over:

amade-solitaire-game · domain/Klondike.java
    /**
     * Turns one or three cards from the stock onto the waste; with an empty stock, the waste is turned
     * over to become the stock again.
     *
     * @return true if anything changed
     */
    public boolean draw() {
        Pile stock = pile(PileId.STOCK);
        Pile waste = pile(PileId.WASTE);
        if (stock.isEmpty()) {
            if (waste.isEmpty()) return false;
            List<Card> cards = waste.takeFrom(0);
            Collections.reverse(cards);
            for (Card c : cards) stock.add(c, false);
        } else {
            int count = drawThree ? Math.min(3, stock.size()) : 1;
            for (int i = 0; i < count; i++) waste.add(stock.takeFrom(stock.size() - 1).getFirst(), true);
        }
        moves++;
        return true;
    }

Step 3: Positions as text

Testing card games has a problem: building a position takes many lines of code, and those lines hide what the test is about. Mines solved this with fields drawn as text ("..*."). Solitaire does the same for card positions:

stock: 2C 3C          two cards in the stock
f0: AS                the ace of spades on the first foundation
t0: [KD 9C] 8H 7S     column 0: two face-down cards, then 8♥ and 7♠ face up

A card is a rank letter (A 2 … 9 T J Q K) and a suit letter (C D H S). Brackets mark the face-down cards. Klondike.of(...) reads this, and toString() writes it, so a failing test prints the position in the same form. Now tests read like a description of the table:

amade-solitaire-game · test/domain/KlondikeTest.java
    @Test
    void columnsBuildDownInAlternatingColours() {
        Klondike game = Klondike.of(false, "t0: 8H", "t1: 7S", "t2: 7D", "t3: 6S", "waste: KC");
        assertTrue(game.canMove(PileId.tableau(1), 0, PileId.tableau(0)), "black 7 on red 8");
        assertFalse(game.canMove(PileId.tableau(2), 0, PileId.tableau(0)), "red 7 on red 8");
        assertFalse(game.canMove(PileId.tableau(3), 0, PileId.tableau(0)), "6 on 8");
        assertTrue(game.canMove(PileId.WASTE, 0, PileId.tableau(4)), "a king on an empty column");
        assertFalse(game.canMove(PileId.tableau(1), 0, PileId.tableau(4)), "only kings on empty columns");
    }
amade-solitaire-game · test/domain/KlondikeTest.java
    @Test
    void drawingThreeTurnsThemOverOneByOne() {
        Klondike game = Klondike.of(true, "stock: 2C 3C 4C 5C");
        game.draw();
        assertEquals(cards("5C 4C 3C"), game.pile(PileId.WASTE).cards(), "the third card ends on top");
        game.draw();
        assertEquals(cards("5C 4C 3C 2C"), game.pile(PileId.WASTE).cards());
        assertTrue(game.pile(PileId.STOCK).isEmpty());
        game.draw();
        assertEquals(cards("2C 3C 4C 5C"), game.pile(PileId.STOCK).cards(), "the waste turned over again");
        assertTrue(game.pile(PileId.WASTE).isEmpty());
        assertEquals(3, game.moves());
        assertFalse(Klondike.of(true).draw(), "nothing to draw");
    }

Invest in test notation

Twenty lines for a little parser feel like a detour. They are not: every later test is shorter and clearer, and you will write many more tests because each one is cheap. Look for such a notation in every game: a maze as text, a level as text, a game state as text.

Step 4: Undo by snapshots

How do you undo a move? The obvious way is to write, for every kind of move, its opposite: put the cards back, turn the uncovered card face down again, lower the move counter. This is the command pattern, and it is easy to get wrong. Forget one side effect and undo slowly corrupts the game.

Solitaire uses a simpler idea: before each move, remember a copy of the whole game. Undo means: throw away the current game and continue with the copy. A Klondike game is 52 small values in thirteen lists, so a copy costs almost nothing, and it cannot forget anything.

The engine provides the bookkeeping, a History of snapshots:

amade-game-engine · undo/History.java
    /** Remembers the state before a move; a new move makes the undone moves unreachable. */
    public void record(S before) {
        past.push(Objects.requireNonNull(before, "before"));
        if (past.size() > limit) past.removeLast();
        future.clear();
    }

    /**
     * Goes one move back.
     *
     * @param current the state now, kept for {@link #redo}
     * @return the state before the last move, empty if there is none
     */
    public Optional<S> undo(S current) {
        if (past.isEmpty()) return Optional.empty();
        future.push(Objects.requireNonNull(current, "current"));
        return Optional.of(past.pop());
    }

    /** Goes forward again after {@link #undo}; empty if nothing was undone. */
    public Optional<S> redo(S current) {
        if (future.isEmpty()) return Optional.empty();
        past.push(Objects.requireNonNull(current, "current"));
        return Optional.of(future.pop());
    }

undo takes the current state, so that redo can return to it. A new move clears the redo list: after taking a move back and playing a different one, the old future is gone. The presenter wraps every move in the same few lines:

amade-solitaire-game · ui/main/SolitairePresenter.java
    public void undo() {
        if (game.won()) return;
        history.undo(game.copy()).ifPresent(previous -> game = previous);
        refresh();
    }

    public void redo() {
        if (game.won()) return;
        history.redo(game.copy()).ifPresent(next -> game = next);
        refresh();
    }

    /**
     * Plays a move: remembers the game before it for undo, starts the clock with the first move. If the
     * move is not allowed the table is shown again anyway, so a dropped card flies back to where it came from.
     */
    private void play(BooleanSupplier move) {
        if (game.won() || finishing) {
            refresh();
            return;
        }
        Klondike before = game.copy();
        if (move.getAsBoolean()) {
            history.record(before);
            clock.start();
            view.setTicking(true);
        }
        refresh();
    }

For this to work, Klondike.copy() must be a deep copy: new piles with new lists. The cards themselves may be shared, because they are immutable. Here immutability pays off, as promised in step 1. A test makes sure nothing leaks between the copy and the original, and another plays undo and redo through the presenter:

amade-solitaire-game · test/ui/main/SolitairePresenterTest.java
    @Test
    void undoAndRedoRestoreTheTable() {
        presenter.play(Klondike.of(false, "t0: [KD 9C] 8H 7S", "t1: 9S"));
        presenter.move(PileId.tableau(0), 2, PileId.tableau(1));
        assertEquals(3, view.game.pile(PileId.tableau(1)).size());
        assertEquals(1, view.game.pile(PileId.tableau(0)).faceDown(), "the 9 of clubs was turned over");

        presenter.undo();
        assertEquals(1, view.game.pile(PileId.tableau(1)).size());
        assertEquals(2, view.game.pile(PileId.tableau(0)).faceDown(), "and is face down again");
        assertFalse(view.canUndo);
        assertTrue(view.canRedo);

        presenter.redo();
        assertEquals(3, view.game.pile(PileId.tableau(1)).size());
        assertEquals(1, view.moves);
    }

When snapshots are not enough

Snapshots need memory for every step. For a strategy game with thousands of units and a long game, copying everything before every order is too much. There, the command pattern returns, or snapshots are taken only every few moves. In part III we will meet that trade-off again. For card and board games, snapshots are the right tool.

Step 5: Drawing cards

Every card is drawn from shapes, like the mines and faces of the previous chapter, but now the pictures are richer. CardPainter draws:

  • the face: a white rounded rectangle, the index (rank and small suit) in two corners, and in the middle either pips or a court picture;
  • the back: a lattice in the accent colour of the desktop theme, so it changes with the Style Manager;
  • empty places: a faint outline on the table cloth.

Shapes in a unit square

A suit symbol is a Path2D, a path of lines and Bézier curves, defined once in a square from 0 to 1:

amade-solitaire-game · ui/view/CardPainter.java
    /**
     * A suit symbol centred on (cx, cy), {@code size} wide, upside down if {@code turned}. The shapes are
     * defined in a unit square and scaled, the way vector graphics work.
     */
    static void fillSuit(Graphics2D g, Suit suit, double cx, double cy, double size, boolean turned) {
        AffineTransform at = new AffineTransform();
        at.translate(cx, cy);
        if (turned) at.rotate(Math.PI);
        at.scale(size, size);
        at.translate(-0.5, -0.5);
        g.fill(at.createTransformedShape(shape(suit)));
    }

    static Shape shape(Suit suit) {
        Path2D p = new Path2D.Double();
        switch (suit) {
            case DIAMONDS -> {
                p.moveTo(0.5, 0);
                p.lineTo(0.9, 0.5);
                p.lineTo(0.5, 1);
                p.lineTo(0.1, 0.5);
                p.closePath();
            }
            case HEARTS -> {
                p.moveTo(0.5, 1);
                p.curveTo(0.15, 0.7, 0, 0.5, 0, 0.3);
                p.curveTo(0, 0.05, 0.4, -0.05, 0.5, 0.25);
                p.curveTo(0.6, -0.05, 1, 0.05, 1, 0.3);
                p.curveTo(1, 0.5, 0.85, 0.7, 0.5, 1);
                p.closePath();
            }
            case SPADES -> {
                p.moveTo(0.5, 0);
                p.curveTo(0.85, 0.3, 1, 0.45, 1, 0.62);
                p.curveTo(1, 0.85, 0.65, 0.9, 0.53, 0.72);
                p.lineTo(0.62, 1);
                p.lineTo(0.38, 1);
                p.lineTo(0.47, 0.72);
                p.curveTo(0.35, 0.9, 0, 0.85, 0, 0.62);
                p.curveTo(0, 0.45, 0.15, 0.3, 0.5, 0);
                p.closePath();
            }
            case CLUBS -> {
                p.append(new Ellipse2D.Double(0.29, 0, 0.42, 0.42), false);
                p.append(new Ellipse2D.Double(0, 0.36, 0.42, 0.42), false);
                p.append(new Ellipse2D.Double(0.58, 0.36, 0.42, 0.42), false);
                p.moveTo(0.44, 0.5);
                p.lineTo(0.56, 0.5);
                p.lineTo(0.64, 1);
                p.lineTo(0.36, 1);
                p.closePath();
            }
        }
        return p;
    }

fillSuit places the unit shape with an AffineTransform: move to the centre, rotate if needed, scale to the size, and shift by half a unit so the shape is centred. Read the transform calls from bottom to top: that is the order in which they act on the shape. The same symbol then serves as a tiny corner index, a pip and a large ace, each just a different size.

Pips

Where do the seven pips of a seven go? Printed cards follow a traditional layout, here stored as data:

amade-solitaire-game · ui/view/CardPainter.java
    private static final double[][][] PIPS = {
            {{0.5, 0.5}},
            {{0.5, 0.1}, {0.5, 0.9}},
            {{0.5, 0.1}, {0.5, 0.5}, {0.5, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.5, 0.5}, {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.2, 0.5}, {0.8, 0.5}, {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.5, 0.3}, {0.2, 0.5}, {0.8, 0.5}, {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.5, 0.3}, {0.2, 0.5}, {0.8, 0.5}, {0.5, 0.7}, {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.2, 0.37}, {0.8, 0.37}, {0.5, 0.5}, {0.2, 0.63}, {0.8, 0.63},
                    {0.2, 0.9}, {0.8, 0.9}},
            {{0.2, 0.1}, {0.8, 0.1}, {0.5, 0.24}, {0.2, 0.37}, {0.8, 0.37}, {0.2, 0.63}, {0.8, 0.63},
                    {0.5, 0.76}, {0.2, 0.9}, {0.8, 0.9}},
    };

Each pair is a position inside the card's inner area, as fractions of its width and height. Pips in the lower half are drawn upside down, as on real cards. A table of numbers replaces ten nearly identical drawing methods. When code repeats with different numbers, put the numbers in a table.

Turning the second index

amade-solitaire-game · ui/view/CardPainter.java
    /** The face of a card: indices in two corners, pips or a court picture in the middle. */
    public static void paintFace(Graphics2D g, Card card, double x, double y, double w, double h) {
        Graphics2D c = (Graphics2D) g.create();
        c.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        c.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
        RoundRectangle2D outline = outline(x, y, w, h);
        c.setColor(FACE);
        c.fill(outline);
        c.setColor(EDGE);
        c.draw(outline);

        Color ink = card.red() ? RED : Color.BLACK;
        c.setColor(ink);
        paintIndex(c, card, x, y, w, h);
        // The other index: the same drawing turned by 180° around the card's centre.
        Graphics2D turned = (Graphics2D) c.create();
        turned.rotate(Math.PI, x + w / 2, y + h / 2);
        paintIndex(turned, card, x, y, w, h);
        turned.dispose();

        // The middle, inside the indices.
        double ix = x + w * 0.22;
        double iy = y + h * 0.12;
        double iw = w * 0.56;
        double ih = h * 0.76;
        if (card.rank().value() <= 10) {
            double pip = w * (card.rank() == Rank.ACE ? 0.42 : 0.2);
            for (double[] p : PIPS[card.rank().value() - 1]) {
                double cx = ix + p[0] * iw;
                double cy = iy + p[1] * ih;
                fillSuit(c, card.suit(), cx, cy, pip, p[1] > 0.5);
            }
        } else {
            paintCourt(c, card, ix, iy, iw, ih, ink);
        }
        c.dispose();
    }

The index in the bottom right corner is the top left one, turned by 180° around the card's centre. Instead of computing mirrored coordinates, the code rotates a copy of the Graphics2D (turned.rotate(Math.PI, centreX, centreY)) and draws the same index again. Transforms let you draw in the most convenient coordinates and let Java2D do the geometry.

Step 6: Where things lie, and what is under the mouse

TableLayout turns piles into pixels. It knows the card size, the gaps, how far face-down and face-up cards of a column are fanned, and that in draw-three mode the top three waste cards fan out sideways:

amade-solitaire-game · ui/view/TableLayout.java
    /** The top left corner of a pile's place. */
    public Point2D slot(PileId id) {
        double column = switch (id.kind()) {
            case STOCK -> 0;
            case WASTE -> 1;
            case FOUNDATION -> 3 + id.index();
            case TABLEAU -> id.index();
        };
        double x = gap + column * (cardWidth + gap);
        double y = id.kind() == PileId.Kind.TABLEAU ? row2() : gap;
        return new Point2D.Double(x, y);
    }

    /** The top left corner of card {@code index} of a pile: columns fan downwards, the waste sideways. */
    public Point2D cardAt(Klondike game, PileId id, int index) {
        Point2D slot = slot(id);
        Pile pile = game.pile(id);
        double x = slot.getX();
        double y = slot.getY();
        switch (id.kind()) {
            case TABLEAU -> {
                for (int i = 0; i < index; i++) y += pile.faceUp(i) ? upStep() : downStep();
            }
            case WASTE -> {
                // Draw three: the top three cards fan out, so the player sees what comes next.
                int shown = game.drawThree() ? Math.min(3, pile.size()) : 1;
                int fanned = index - (pile.size() - shown);
                if (fanned > 0) x += fanned * cardWidth * 0.22;
            }
            default -> {
            }
        }
        return new Point2D.Double(x, y);
    }

The reverse question is the hard part of any card game: which card is under the mouse? Cards overlap, and the one drawn last lies on top. So the search goes through each pile from the top card down and takes the first card whose rectangle contains the point. Where do dragged cards land? On the pile whose top card they overlap most:

amade-solitaire-game · ui/view/TableLayout.java
    /** The card under a point, the topmost if cards overlap; a click on an empty place gives index -1. */
    public Optional<Hit> hit(Klondike game, Point2D point) {
        for (PileId id : PileId.all()) {
            Pile pile = game.pile(id);
            for (int i = pile.size() - 1; i >= 0; i--) {
                if (bounds(game, id, i).contains(point)) return Optional.of(new Hit(id, i));
            }
            Point2D slot = slot(id);
            if (pile.isEmpty() && new Rectangle2D.Double(slot.getX(), slot.getY(), cardWidth, cardHeight)
                    .contains(point)) {
                return Optional.of(new Hit(id, -1));
            }
        }
        return Optional.empty();
    }

    /**
     * Where dragged cards land: the foundation or column whose top card (or empty place) overlaps most with
     * the first dragged card. The pile they come from does not count. Empty if it touches none.
     */
    public Optional<PileId> dropTarget(Klondike game, Rectangle2D dragged, PileId from) {
        PileId best = null;
        double most = 0;
        for (PileId id : PileId.all()) {
            if (id.equals(from) || id.kind() != PileId.Kind.FOUNDATION && id.kind() != PileId.Kind.TABLEAU) continue;
            Pile pile = game.pile(id);
            Rectangle2D target = pile.isEmpty()
                    ? new Rectangle2D.Double(slot(id).getX(), slot(id).getY(), cardWidth, cardHeight)
                    : bounds(game, id, pile.size() - 1);
            Rectangle2D overlap = target.createIntersection(dragged);
            double area = overlap.isEmpty() ? 0 : overlap.getWidth() * overlap.getHeight();
            if (area > most) {
                most = area;
                best = id;
            }
        }
        return Optional.ofNullable(best);
    }

TableLayout is pure arithmetic: it uses Rectangle2D and Point2D but no window. So it is tested like a model, without a display. TableLayoutTest checks the places of the piles, the fanning, and which card a click at a given point hits.

Step 7: Animation

A card that teleports from the waste to a column looks cheap; a card that flies there over a fifth of a second looks right. Animation needs three ingredients, and the engine provides them.

Easing: how a movement feels

Moving at constant speed looks mechanical. Real objects speed up and slow down. An easing function maps the elapsed share of an animation's time (0 to 1) to the share of the way covered (0 to 1):

amade-game-engine · anim/Easing.java
@FunctionalInterface
public interface Easing {
    double apply(double t);

    /** Constant speed. */
    Easing LINEAR = t -> t;

    /** Fast start, gentle stop: for things that are thrown and land, like a card on its pile. */
    Easing EASE_OUT = t -> 1 - Math.pow(1 - t, 3);

    /** Gentle start and stop: for things that are carried. */
    Easing EASE_IN_OUT = t -> t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

With EASE_OUT, a card covers 87 % of its way in the first half of the time and glides into place. Try LINEAR instead and compare: the difference is surprisingly large.

Tween: one animation in time

A Tween (from "in-between") knows when an animation started, how long it takes and how it eases. Ask it how far along it is at a given time, and interpolate any value with lerp:

amade-game-engine · anim/Tween.java
    /** The eased share of the way at a time: 0 before the start, 1 from the end on. */
    public double at(long now) {
        double t = (double) (now - start) / duration;
        return easing.apply(Math.clamp(t, 0.0, 1.0));
    }

    public boolean done(long now) {
        return now - start >= duration;
    }

    /** The value a share {@code t} of the way from {@code from} to {@code to}. */
    public static double lerp(double from, double to, double t) {
        return from + (to - from) * t;
    }

Neither class knows Swing or cards. They are plain functions of time, and therefore tested with plain numbers.

Animator: frames on the event thread

Something must redraw the component about 60 times a second while cards move, and stop when they arrive. That is the engine's Animator, a Swing Timer with a twist:

amade-game-engine · swing/Animator.java
package eu.amadeos.gameengine.swing;

import javax.swing.Timer;
import java.awt.Toolkit;
import java.util.function.LongPredicate;

/**
 * Drives animations in a Swing component: calls a frame function about 60 times a second on the event
 * thread with the current {@link System#nanoTime()}, for as long as the function says there is more to
 * show. Starting it while it runs does nothing, so every change can simply call {@link #start()}.
 *
 * <pre>
 * Animator animator = new Animator(now -&gt; {
 *     boolean more = updatePositions(now);
 *     repaint();
 *     return more;
 * });
 * </pre>
 */
public final class Animator {
    /** Milliseconds between frames: about 60 frames a second. */
    public static final int FRAME = 16;

    private final Timer timer;

    /** @param frame updates the animated values for the time given and returns false once all are done */
    public Animator(LongPredicate frame) {
        timer = new Timer(FRAME, null);
        timer.addActionListener(e -> {
            boolean more = frame.test(System.nanoTime());
            // Without this, X11 may hold the drawing back and the animation stutters.
            Toolkit.getDefaultToolkit().sync();
            if (!more) timer.stop();
        });
        timer.setCoalesce(true);
    }

    public void start() {
        if (!timer.isRunning()) timer.start();
    }

    public void stop() {
        timer.stop();
    }

    public boolean running() {
        return timer.isRunning();
    }
}

The frame function returns whether anything still moves; when it says no, the timer stops, and an idle game uses no CPU. Toolkit.getDefaultToolkit().sync() after each frame matters on Linux: without it, X11 may collect drawing commands and show several frames at once, and the animation stutters.

Pitfall: a Swing Timer is not a game loop

A javax.swing.Timer is fine for short animations: it runs on the event thread, so the code may touch components directly. But it is not precise; frames come a few milliseconds early or late. For a card that flies 0.2 seconds, nobody notices. For an action game where the ball must move smoothly for minutes, it is not good enough. Part II builds a real game loop.

One idea animates everything

Now the elegant part. How does the view know which cards to animate after a move, an undo, a new deal or a failed drop? It does not ask. It remembers where it drew each card, and when a new state arrives, it computes where each card belongs now. Every card whose place changed flies from the old place to the new one:

amade-solitaire-game · ui/view/TableView.java
    /** Shows a new state of the game; every card that changed place flies there. */
    public void showGame(Klondike newGame) {
        if (celebration != null && !newGame.won()) celebration = null;
        long now = System.nanoTime();
        for (Map.Entry<Card, Point2D> place : layout.places(newGame).entrySet()) {
            Card card = place.getKey();
            Point2D target = place.getValue();
            Point2D current = position(card, now);
            if (current == null || current.distance(target) < 0.5) {
                flights.remove(card);
            } else {
                flights.put(card, new Flight(current, target, new Tween(now, FLIGHT, Easing.EASE_OUT)));
            }
            shown.put(card, target);
        }
        game = newGame;
        if (!flights.isEmpty()) animator.start();
        repaint();
    }

    /** Where a card is drawn at a time: on its way, or at its place. */
    private Point2D position(Card card, long now) {
        Flight flight = flights.get(card);
        return flight != null ? flight.at(now) : shown.get(card);
    }

    /** One animation frame: lands the cards that arrived; true while something still moves. */
    private boolean frame(long now) {
        flights.values().removeIf(f -> f.tween().done(now));
        if (celebration != null) celebration.step(now);
        repaint();
        return !flights.isEmpty() || celebration != null && !celebration.finished();
    }

This one rule covers every situation:

What happened What the view sees What the player sees
A card moved to a column its place changed it flies there
Undo several places changed back the cards fly back
New deal every place changed the cards fly from their old places into the new deal
A card dropped where it may not go the presenter shows the unchanged game, but the card was last drawn where it was dropped it flies back home

The presenter knows nothing about any of this. It changes the model and calls showGame. Animation is purely the view's business, exactly as MVP intends.

Step 8: Drag and drop

Dragging is three mouse events:

amade-solitaire-game · ui/view/TableView.java
    private void pressed(MouseEvent e) {
        if (game == null) return;
        if (celebration != null) {
            celebration = null;
            repaint();
            return;
        }
        Point2D point = e.getPoint();
        Optional<TableLayout.Hit> hit = layout.hit(game, point);
        if (hit.isEmpty()) return;
        PileId pile = hit.get().pile();
        int index = hit.get().index();
        if (pile.equals(PileId.STOCK)) {
            listener.draw();
        } else if (e.getClickCount() == 2 && index >= 0 && index == game.pile(pile).size() - 1) {
            drag = null;
            listener.moveToFoundation(pile);
        } else if (game.canPick(pile, index)) {
            List<Card> cards = new ArrayList<>(game.pile(pile).cards().subList(index, game.pile(pile).size()));
            for (Card c : cards) flights.remove(c);
            drag = new Drag(pile, index, cards, shown.get(cards.getFirst()), point);
        }
    }

    private void dragged(MouseEvent e) {
        if (drag == null) return;
        drag.mouse = e.getPoint();
        if (drag.mouse.distance(drag.pressed) > DRAG_THRESHOLD) drag.moved = true;
        repaint();
    }

    private void released() {
        if (drag == null) return;
        Drag done = drag;
        drag = null;
        if (!done.moved) {
            repaint();
            return;
        }
        Rectangle2D first = new Rectangle2D.Double(done.mouse.getX() - done.grabX, done.mouse.getY() - done.grabY,
                layout.cardWidth(), layout.cardHeight());
        PileId target = layout.dropTarget(game, first, done.from).orElse(done.from);
        // The cards are where they were dropped; from there they fly to wherever the presenter's answer puts them.
        for (int i = 0; i < done.cards.size(); i++) shown.put(done.cards.get(i), draggedAt(done, i));
        listener.move(done.from, done.index, target);
    }
  • Press: find the card under the mouse. A click on the stock draws. A double click on a top card sends it to its foundation. On a card that may be picked up, the drag begins. The view remembers where on the card the mouse grabbed it (grabX, grabY), so the card does not jump to put its corner under the pointer.
  • Drag: remember the mouse position and repaint. The dragged cards are drawn last, on top of everything, at the mouse position minus the grab offset.
  • Release: find the pile the cards overlap most and report the move. Before that, the view records the drop position as the cards' current place. If the move is legal, they glide the last bit into the column; if not, the presenter shows the unchanged game and they fly back. No special code for failed drops exists anywhere.

A press that moves less than three pixels is not a drag. Without that threshold, a slightly shaky double click would become a tiny drag and the second click would be lost.

The drawing order is the key to making all this look right:

amade-solitaire-game · ui/view/TableView.java
    /** Felt, empty places, resting cards, then flying cards, then the cards under the mouse. */
    private void paintTable(Graphics2D g, long now) {
        g.setColor(FELT);
        g.fillRect(0, 0, getWidth(), getHeight());
        if (game == null) return;
        DesktopTheme theme = AmadeUI.theme();
        double w = layout.cardWidth();
        double h = layout.cardHeight();
        List<Runnable> flying = new ArrayList<>();
        for (PileId id : PileId.all()) {
            Point2D slot = layout.slot(id);
            boolean ring = id.equals(PileId.STOCK) && !game.pile(PileId.WASTE).isEmpty();
            CardPainter.paintSlot(g, slot.getX(), slot.getY(), w, h, ring);
            Pile pile = game.pile(id);
            for (int i = 0; i < pile.size(); i++) {
                Card card = pile.cards().get(i);
                if (drag != null && drag.cards.contains(card)) continue;
                boolean up = pile.faceUp(i);
                Flight flight = now == Long.MAX_VALUE ? null : flights.get(card);
                if (flight != null) {
                    Point2D p = flight.at(now);
                    flying.add(() -> paintCard(g, theme, card, up, p));
                } else {
                    paintCard(g, theme, card, up, shown.get(card));
                }
            }
        }
        flying.forEach(Runnable::run);
        if (drag != null) {
            for (int i = 0; i < drag.cards.size(); i++) paintCard(g, theme, drag.cards.get(i), true, draggedAt(drag, i));
        }
    }

Resting cards first, pile by pile; then flying cards, so they pass over the others; then the cards in the player's hand.

Step 9: The presenter and the end of the game

The presenter follows the pattern from Mines. Its new duties are undo (step 4) and the end of the game:

amade-solitaire-game · ui/main/SolitairePresenter.java
    /** Shows the table and the counters; starts finishing an open game and celebrates a won one. */
    private void refresh() {
        view.showGame(game);
        view.showMoves(game.moves());
        boolean justWon = game.won() && !over;
        if (justWon) {
            over = true;
            clock.stop();
            history.clear();
            finishing = false;
            view.setTicking(false);
            view.setFinishing(false);
        } else if (!finishing && game.canFinish()) {
            finishing = true;
            view.setFinishing(true);
        }
        view.showTime(clock.seconds());
        view.showUndo(history.canUndo(), history.canRedo());
        if (justWon) {
            view.celebrate();
            recordWin(clock.seconds());
        }
    }

When the game can finish itself, the presenter asks the view for a timer (setFinishing(true)), which calls finishStep() every 90 milliseconds. Each call moves the lowest card that fits to a foundation, and the view animates it. The rhythm is the view's; the decision is the presenter's, and so is the test:

amade-solitaire-game · test/ui/main/SolitairePresenterTest.java
    @Test
    void anOpenGameFinishesItselfAndRecordsTheTime() {
        view.names.push(Optional.of("Anna"));
        presenter.play(Klondike.of(false, ALMOST_WON));
        presenter.move(PileId.WASTE, 0, PileId.foundation(3));     // the last move by hand
        assertTrue(view.finishing, "now the rest plays itself");
        now[0] += 41_500_000_000L;
        while (view.finishing) presenter.finishStep();              // what the view's timer does
        assertTrue(view.game.won());
        assertEquals(1, view.celebrations);
        assertFalse(view.ticking);
        assertEquals("You won with \"Draw one\" in 42 seconds.", view.askedWith);
        assertEquals(List.of(new Score("Anna", 42, WON_AT)), scores.list("draw-one"));
        assertEquals(Boolean.FALSE, view.scoresShownForDrawThree);
        assertEquals(0, view.highlight);
    }

The test plays the view's timer in a simple loop: while (view.finishing) presenter.finishStep(). Ten lines test the whole ending (finishing, celebration, clock, high score) in a few milliseconds.

Step 10: The bouncing cards

The victory cascade

The ending: cards bounce off the bottom edge and leave trails, while the game asks for your name.

Windows 3.0 ended a won game with cards bouncing across the screen and leaving trails, and generations of players remember it. The trick behind the trails is simple: never clear the picture. The view takes a snapshot of the table into a BufferedImage and draws each card's new position on top of the old ones.

amade-solitaire-game · ui/view/TableView.java
    /**
     * The famous ending: one card after another jumps off the foundations, kings first, falls, bounces on
     * the bottom edge and leaves a trail, because the picture is never cleared between frames.
     */
    private final class Celebration {
        final BufferedImage trail;
        final List<Card> queue = new ArrayList<>();
        final Map<Card, Point2D> starts = new HashMap<>();
        final Random random = new Random();
        Card card;
        double x, y, vx, vy;
        long last = -1;

        Celebration(BufferedImage trail) {
            this.trail = trail;
            for (int value = 13; value >= 1; value--) {
                for (int f = 0; f < PileId.FOUNDATIONS; f++) {
                    PileId id = PileId.foundation(f);
                    if (game.pile(id).size() >= value) {
                        Card c = game.pile(id).cards().get(value - 1);
                        queue.add(c);
                        starts.put(c, layout.cardAt(game, id, value - 1));
                    }
                }
            }
        }

        boolean finished() {
            return card == null && queue.isEmpty();
        }

        void step(long now) {
            // Physics in steps of 1/60 s, however often frames come.
            int steps = last < 0 ? 1 : (int) Math.clamp((now - last) / 16_000_000, 1, 4);
            last = now;
            double w = layout.cardWidth();
            double h = layout.cardHeight();
            Graphics2D g = trail.createGraphics();
            for (int s = 0; s < steps; s++) {
                if (card == null) {
                    if (queue.isEmpty()) break;
                    card = queue.removeFirst();
                    Point2D start = starts.get(card);
                    x = start.getX();
                    y = start.getY();
                    vx = (random.nextBoolean() ? 1 : -1) * w * (0.03 + random.nextDouble() * 0.07);
                    vy = -h * random.nextDouble() * 0.08;
                }
                vy += h * 0.012;
                x += vx;
                y += vy;
                if (y + h > trail.getHeight()) {
                    y = trail.getHeight() - h;
                    vy = -vy * 0.75;
                }
                CardPainter.paintFace(g, card, x, y, w, h);
                if (x + w < 0 || x > trail.getWidth()) card = null;
            }
            g.dispose();
        }
    }

This is your first physics simulation, in a few lines:

  • every card has a position (x, y) and a velocity (vx, vy);
  • each step, gravity adds to the vertical velocity (vy += …), and the velocity moves the card;
  • at the bottom edge the vertical velocity turns around and loses a quarter (vy = -vy * 0.75): the bounce.

The physics runs in fixed steps of 1/60 second. If a frame comes late, it simply does two or three steps. That way the cards fall at the same speed on a fast and a slow computer. Keep this idea in mind, because it is the heart of the game loop in part II.

Step 11: What moved into the engine

From Solitaire Engine package Why
Undo and redo by snapshots eu.amadeos.gameengine.undo.History Every turn-based game wants it, and the bookkeeping is always the same
Undo (Ctrl+Z) and Redo (Ctrl+Y) eu.amadeos.gameengine.swing.GameMenus Same keys and texts everywhere
Easing curves and tweens eu.amadeos.gameengine.anim Every game animates something
The animation timer eu.amadeos.gameengine.swing.Animator Same frame handling, including Toolkit.sync()
The game clock (from Mines!) eu.amadeos.gameengine.time.GameClock The second game that measured time

The GameClock is the rule of this book in action: Mines wrote it for itself, and when Solitaire needed the same thing, it moved into the engine, and Mines now uses it from there.

What stays in the game? Cards, piles, the layout and the card painter. A second card game (Spider or FreeCell) would move the cards and the painter into the engine. Until then they stay here.

Exercises

  1. Scoring. Add the classic Windows scoring: +10 for a card to a foundation, +5 for waste to column and for turning a card over, −15 for a card back from a foundation, −100 for turning the waste over in draw-one mode. Show the score in the status line. Where does the score live, and what happens to it on undo? (Hint: if it lives in Klondike, undo restores it for free.)
  2. Hints. Press H to highlight a legal move. Which class finds it? Write a test for "no move possible".
  3. Flip animation. When a face-down card is uncovered, show it turning: shrink it horizontally to nothing, switch to the face, grow back. Which easing fits?
  4. Spider. Implement Spider solitaire (two decks, ten columns, runs of one suit). Which classes can you reuse unchanged? Which ones does that tell you should move into the engine?
  5. Statistics. Games played, games won, longest winning streak, per draw mode. Which package stores them, and in which file?

With Solitaire, part I ends. Both games so far waited for the player. In part II the world starts moving by itself: in Paddle, a ball flies whether you press a key or not, and we build the game loop that keeps it moving.