Home page > LÖVE tutorials

Blackjack

A tutorial for Lua and LÖVE 11

Download blackjack.love

Rules

The dealer and player are dealt two cards each. The dealer's first card is hidden from the player.

The player can hit (i.e. take another card) or stand (i.e. stop taking cards).

If the total value of the player's hand goes over 21, then they have gone bust.

Face cards (king, queen and jack) have a value of 10, and aces have a value of 11 unless this would make the total value of the hand go above 21, in which case they have a value of 1.

After the player has stood or gone bust, the dealer takes cards until the total of their hand is 17 or over.

The round is then over, and the hand with the highest total (if the total is 21 or under) wins the round.

Controls

Left clickClick on hit or stand button

Overview

Each card is represented by a table containing a string representing its suit, and a number representing its rank. Jacks, queens and kings are represented by the numbers 11, 12 and 13.

The deck is represented by a table which initially contains one of every card.

The player's and dealer's hands are represented by tables, and cards are removed from the deck at random positions and inserted into their hands when they take cards.

The cards are assembled from the following images, with the images colored or flipped horizontally or vertically as necessary:

Coding

The deck of cards

Each card is represented by a table containing a string representing its suit, and a number representing its rank. Jacks, queens and kings are represented by the numbers 11, 12 and 13.

A table for the deck is created containing one of every card.

Full code at this point

function love.load()
    deck = {}
    for suitIndex, suit in ipairs({'club', 'diamond', 'heart', 'spade'}) do
        for rank = 1, 13 do
            table.insert(deck, {suit = suit, rank = rank})
            -- Temporary
            print('suit: '..suit..', rank: '..rank)
        end
    end

    -- Temporary
    print('Total number of cards in deck: '..#deck)
end
suit: club, rank: 1
suit: club, rank: 2
suit: club, rank: 3
suit: club, rank: 4
suit: club, rank: 5
suit: club, rank: 6
suit: club, rank: 7
suit: club, rank: 8
suit: club, rank: 9
suit: club, rank: 10
suit: club, rank: 11
suit: club, rank: 12
suit: club, rank: 13
suit: diamond, rank: 1
suit: diamond, rank: 2
suit: diamond, rank: 3
suit: diamond, rank: 4
suit: diamond, rank: 5
suit: diamond, rank: 6
suit: diamond, rank: 7
suit: diamond, rank: 8
suit: diamond, rank: 9
suit: diamond, rank: 10
suit: diamond, rank: 11
suit: diamond, rank: 12
suit: diamond, rank: 13
suit: heart, rank: 1
suit: heart, rank: 2
suit: heart, rank: 3
suit: heart, rank: 4
suit: heart, rank: 5
suit: heart, rank: 6
suit: heart, rank: 7
suit: heart, rank: 8
suit: heart, rank: 9
suit: heart, rank: 10
suit: heart, rank: 11
suit: heart, rank: 12
suit: heart, rank: 13
suit: spade, rank: 1
suit: spade, rank: 2
suit: spade, rank: 3
suit: spade, rank: 4
suit: spade, rank: 5
suit: spade, rank: 6
suit: spade, rank: 7
suit: spade, rank: 8
suit: spade, rank: 9
suit: spade, rank: 10
suit: spade, rank: 11
suit: spade, rank: 12
suit: spade, rank: 13
Total number of cards in deck: 52

Dealing the player's hand

A table for the player's hand is created.

A card is removed from the deck table at a random index between 1 and the number of cards in the deck. This card is inserted into the player's hand.

This happens again for the player's second card.

Full code at this point

function love.load()
    -- etc.

    playerHand = {}
    table.insert(playerHand, table.remove(deck, love.math.random(#deck)))
    table.insert(playerHand, table.remove(deck, love.math.random(#deck)))

    -- Temporary
    print('Player hand:')
    for cardIndex, card in ipairs(playerHand) do
        print('suit: '..card.suit..', rank: '..card.rank)
    end

    print('Total number of cards in deck: '..#deck)
end
Player hand:
suit: heart, rank: 1
suit: spade, rank: 12
Total number of cards in deck: 50

Displaying the state of the game

For now, information about the state of the game will be displayed as text.

A table is created for the output strings, and is concatenated and drawn to the screen.

Full code at this point

function love.draw()
    local output = {}

    table.insert(output, 'Player hand:')
    for cardIndex, card in ipairs(playerHand) do
        table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
    end

    love.graphics.print(table.concat(output, '\n'), 15, 15)
end

Dealing the dealer's hand

A table is created for the dealer's hand and two random cards from the deck are inserted into it.

Full code at this point

function love.load()
    -- etc.

    playerHand = {}
    table.insert(playerHand, table.remove(deck, love.math.random(#deck)))
    table.insert(playerHand, table.remove(deck, love.math.random(#deck)))

    dealerHand = {}
    table.insert(dealerHand, table.remove(deck, love.math.random(#deck)))
    table.insert(dealerHand, table.remove(deck, love.math.random(#deck)))
end

function love.draw()
    local output = {}

    table.insert(output, 'Player hand:')
    for cardIndex, card in ipairs(playerHand) do
        table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
    end

    table.insert(output, '')

    table.insert(output, 'Dealer hand:')
    for cardIndex, card in ipairs(dealerHand) do
        table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
    end

    love.graphics.print(table.concat(output, '\n'), 15, 15)
end

Simplifying code

The only difference between the code for inserting a card into the player's hand and inserting a card into the dealer's hand is the hand to insert the card into, so a function is made with the hand as a parameter.

Full code at this point

function love.load()
    -- etc.

    local function takeCard(hand)
        table.insert(hand, table.remove(deck, love.math.random(#deck)))
    end

    playerHand = {}
    takeCard(playerHand)
    takeCard(playerHand)

    dealerHand = {}
    takeCard(dealerHand)
    takeCard(dealerHand)
end

Hitting

For now, the keyboard is used for input instead of on-screen buttons.

When the h key is pressed, the player takes a card from the deck.

Since takeCard is used in love.keypressed it is made global.

Full code at this point

function love.load()
    -- etc.

    -- "local" removed
    function takeCard(hand)

    -- etc.
end

function love.keypressed(key)
    if key == 'h' then
        takeCard(playerHand)
    end
end

Getting total value of a hand

For each hand, the rank of each card is added together to get the total value of the hand.

Currently this does not account for face cards having a value of 10 or for the value of aces sometimes being 11.

Full code at this point

function love.draw()
    local function getTotal(hand)
        local total = 0

        for cardIndex, card in ipairs(hand) do
            total = total + card.rank
        end

        return total
    end

    -- etc.

    table.insert(output, 'Total: '..getTotal(playerHand))

    -- etc.

    table.insert(output, 'Total: '..getTotal(dealerHand))

    -- etc.
end

Total accounting for face cards

If a card's rank is higher than 10 (i.e. 11, 12 or 13), then it is a face card and its value is 10.

Full code at this point

function love.draw()
    local function getTotal(hand)
        local total = 0

        for cardIndex, card in ipairs(hand) do
            if card.rank > 10 then
                total = total + 10
            else
                total = total + card.rank
            end
        end

        return total
    end

    -- etc.
end

Total accounting for ace

Aces have a value of 11 instead of 1 unless the value of the hand would go over 21.

First, the values of all of the cards in the hand are added together, counting aces as 1 instead of 11.

Then, if the hand has an ace and the total value of the hand is 11 or less, 10 is added to the total (10 is added instead of 11 because 1 has already been added to it). If the total value of the hand was 12 (or more), then an ace counting as 11 would make the value of the hand 22 (or more).

Full code at this point

function love.draw()
    -- etc.

    local function getTotal(hand)
        local total = 0
        local hasAce = false

        for cardIndex, card in ipairs(hand) do
            if card.rank > 10 then
                total = total + 10
            else
                total = total + card.rank
            end

            if card.rank == 1 then
                hasAce = true
            end
        end

        if hasAce and total <= 11 then
            total = total + 10
        end

        return total
    end

    -- etc.
end

Standing

When the s key is pressed, the player stands and the round is over.

The player can hit only when the round is not over.

Full code at this point

function love.load()
    -- etc.

    roundOver = false
end

function love.keypressed(key)
    if key == 'h' and not roundOver then
        takeCard(playerHand)
    elseif key == 's' then
        roundOver = true
    end
end

Displaying the winner

When the round is over, the total of the player's hand is compared to the total of the dealer's hand and the winner is displayed.

Currently this does not account for busts (i.e. hands with a value of over 21).

Full code at this point

function love.draw()
    -- etc.

    if roundOver then
        table.insert(output, '')

        if getTotal(playerHand) > getTotal(dealerHand) then
            table.insert(output, 'Player wins')
        elseif getTotal(dealerHand) > getTotal(playerHand) then
            table.insert(output, 'Dealer wins')
        else
            table.insert(output, 'Draw')
        end
    end

    -- etc.
end

Winner accounting for bust

The player or dealer wins if they haven't gone bust, and...

Full code at this point

function love.draw()
    -- etc.

    if roundOver then
        table.insert(output, '')

        if getTotal(playerHand) <= 21
        and (
            getTotal(dealerHand) > 21
            or getTotal(playerHand) > getTotal(dealerHand)
        ) then
            table.insert(output, 'Player wins')
        elseif getTotal(dealerHand) <= 21
        and (
            getTotal(playerHand) > 21
            or getTotal(dealerHand) > getTotal(playerHand)
        ) then
            table.insert(output, 'Dealer wins')
        else
            table.insert(output, 'Draw')
        end
    end

    -- etc.
end

Simplifying code

The only differences in the code determining if a hand has won is the hand in question and the opponent's hand, so a function is made.

Full code at this point

function love.draw()
    -- etc.

    if roundOver then
        table.insert(output, '')

        local function hasHandWon(thisHand, otherHand)
            return getTotal(thisHand) <= 21
            and (
                getTotal(otherHand) > 21
                or getTotal(thisHand) > getTotal(otherHand)
            )
        end

        if hasHandWon(playerHand, dealerHand) then
            table.insert(output, 'Player wins')
        elseif hasHandWon(dealerHand, playerHand) then
            table.insert(output, 'Dealer wins')
        else
            table.insert(output, 'Draw')
        end
    end

    -- etc.
end

Play again

When a key is pressed and the round is over, love.load is called (for now) to start another game.

Full code at this point

function love.keypressed(key)
    if not roundOver then
        if key == 'h' then
            takeCard(playerHand)
        elseif key == 's' then
            roundOver = true
        end
    else
        love.load()
    end
end

End round on bust or 21

If the player has gone bust or the value of their hand is already 21, the round is automatically over.

Since this requires getting the total value of a hand, getTotal is moved into love.load.

Full code at this point

function love.load()
    -- etc.

    -- Moved and "local" removed
    function getTotal(hand)
        -- etc.
    end
end

function love.keypressed(key)
    if not roundOver then
        if key == 'h' then
            takeCard(playerHand)
            if getTotal(playerHand) >= 21 then
                roundOver = true
            end
        elseif key == 's' then
            roundOver = true
        end
    else
        love.load()
    end
end

Dealer hitting

If the player has stood, gone bust or has 21, the dealer takes cards while the value of their hand is less than 17.

Full code at this point

function love.keypressed(key)
    if not roundOver then
        if key == 'h' then
            takeCard(playerHand)
            if getTotal(playerHand) >= 21 then
                roundOver = true
            end
        elseif key == 's' then
            roundOver = true
        end

        if roundOver then
            while getTotal(dealerHand) < 17 do
                takeCard(dealerHand)
            end
        end
    else
        love.load()
    end
end

Hiding the dealer's first card

Until the round is over, the dealer's first card (i.e. the first item of the dealer's hand table) is hidden.

Full code at this point

function love.draw()
    -- etc.

    table.insert(output, 'Dealer hand:')
    for cardIndex, card in ipairs(dealerHand) do
        if not roundOver and cardIndex == 1 then
            table.insert(output, '(Card hidden)')
        else
            table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
        end
    end

    -- etc.
end

Hiding the dealer's total

Until the round is over, the total of the dealer's hand is hidden.

Full code at this point

function love.draw()
    -- etc.

    if roundOver then
        table.insert(output, 'Total: '..getTotal(dealerHand))
    else
        table.insert(output, 'Total: ?')
    end

    -- etc.
end

Loading images

All of the images used for drawing cards are loaded into a table.

You can access the image files used in this tutorial by downloading the .love file linked to at the top of this page, then unzipping it by renaming the extension from .love to .zip, or by using 7-Zip and right clicking on the .love file and extracting it.

Full code at this point

function love.load()
    images = {}
    for nameIndex, name in ipairs({
        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
        'pip_heart', 'pip_diamond', 'pip_club', 'pip_spade',
        'mini_heart', 'mini_diamond', 'mini_club', 'mini_spade',
        'card', 'card_face_down',
        'face_jack', 'face_queen', 'face_king',
    }) do
        images[name] = love.graphics.newImage('images/'..name..'.png')
    end

    -- etc.
end

Drawing cards

The line of code which displays the text is removed.

The cards in the player's hand are looped through and (for now) a blank card is drawn for each one.

The background color is set to white.

Full code at this point

function love.load()
    love.graphics.setBackgroundColor(1, 1, 1)

    -- etc.
end

function love.draw()
    -- etc.

    -- Removed: love.graphics.print(table.concat(output, '\n'), 15, 15)

    for cardIndex, card in ipairs(playerHand) do
        love.graphics.draw(images.card, (cardIndex - 1) * 60)
    end
end

Drawing text for suit and rank

For now, the suit and rank of each card is drawn as text.

Full code at this point

function love.draw()
    -- etc.

    for cardIndex, card in ipairs(playerHand) do
        love.graphics.setColor(1, 1, 1)
        love.graphics.draw(images.card, (cardIndex - 1) * 60, 0)

        love.graphics.setColor(0, 0, 0)
        love.graphics.print(card.suit, (cardIndex - 1) * 60, 0)
        love.graphics.print(card.rank, (cardIndex - 1) * 60, 15)
    end
end

Test hands

The code for drawing a card is made into a function, and test hands are created.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        love.graphics.setColor(1, 1, 1)
        love.graphics.draw(images.card, x, y)

        love.graphics.setColor(0, 0, 0)
        love.graphics.print(card.suit, x, y)
        love.graphics.print(card.rank, x, y + 15)
    end

    local testHand1 = {
        {suit = 'club', rank = 1},
        {suit = 'diamond', rank = 2},
        {suit = 'heart', rank = 3},
        {suit = 'spade', rank = 4},
        {suit = 'club', rank = 5},
        {suit = 'diamond', rank = 6},
        {suit = 'heart', rank = 7},
    }

    for cardIndex, card in ipairs(testHand1) do
        drawCard(card, (cardIndex - 1) * 60, 0)
    end

    local testHand2 = {
        {suit = 'spade', rank = 8},
        {suit = 'club', rank = 9},
        {suit = 'diamond', rank = 10},
        {suit = 'heart', rank = 11},
        {suit = 'spade', rank = 12},
        {suit = 'club', rank = 13},
    }

    for cardIndex, card in ipairs(testHand2) do
        drawCard(card, (cardIndex - 1) * 60, 80)
    end
end

Drawing rank

The card's rank is drawn by indexing the image table with the card's rank, which corresponds to the appropriate image file.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        love.graphics.setColor(1, 1, 1)
        love.graphics.draw(images.card, x, y)

        love.graphics.setColor(0, 0, 0)
        love.graphics.draw(images[card.rank], x + 3, y + 4)
    end
end

Drawing bottom number

The card's rank is drawn at the bottom right corner minus the same offset that was added to the number drawn at the top.

The image is flipped both horizontally and vertically.

To calculate the image's X and Y positions, the card's width and height are needed (and they will also be used when drawing the suit and pips), and the offset from the corner is reused from drawing the top number, so variables are made for them.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        love.graphics.setColor(1, 1, 1)
        love.graphics.draw(images.card, x, y)

        love.graphics.setColor(0, 0, 0)

        local cardWidth = 53
        local cardHeight = 73
        local numberOffsetX = 3
        local numberOffsetY = 4

        love.graphics.draw(
            images[card.rank],
            x + numberOffsetX,
            y + numberOffsetY
        )
        love.graphics.draw(
            images[card.rank],
            x + cardWidth - numberOffsetX,
            y + cardHeight - numberOffsetY,
            0,
            -1
        )
    end

    -- etc.
end

Drawing suit

The suit is drawn similarly to the rank.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        local suitOffsetX = 3
        local suitOffsetY = 14
        local suitImage = images['mini_'..card.suit]

        love.graphics.draw(
            suitImage,
            x + suitOffsetX,
            y + suitOffsetY
        )
        love.graphics.draw(
            suitImage,
            x + cardWidth - suitOffsetX,
            y + cardHeight - suitOffsetY,
            0,
            -1
        )
    end

    -- etc.
end

Simplifying code

The only differences between drawing the rank and suit are the image and the offset, so a function is made with these as parameters.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        local cardWidth = 53
        local cardHeight = 73

        local function drawCorner(image, offsetX, offsetY)
            love.graphics.draw(
                image,
                x + offsetX,
                y + offsetY
            )
            love.graphics.draw(
                image,
                x + cardWidth - offsetX,
                y + cardHeight - offsetY,
                0,
                -1
            )
        end

        drawCorner(images[card.rank], 3, 4)
        drawCorner(images['mini_'..card.suit], 3, 14)
    end

    -- etc.
end

Setting color based on suit

If the card's suit is diamonds or hearts, then the color of the rank and suit is red, otherwise for clubs and spades it is black.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        love.graphics.setColor(1, 1, 1)
        love.graphics.draw(images.card, x, y)

        -- Removed: love.graphics.setColor(0, 0, 0)

        if card.suit == 'heart' or card.suit == 'diamond' then
            love.graphics.setColor(.89, .06, .39)
        else
            love.graphics.setColor(.2, .2, .2)
        end

        -- etc.
    end
end

Drawing face cards

If the card's rank is 11, 12 or 13, then the image for the jack, queen or king is drawn.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            local faceImage

            if card.rank == 11 then
                faceImage = images.face_jack
            elseif card.rank == 12 then
                faceImage = images.face_queen
            elseif card.rank == 13 then
                faceImage = images.face_king
            end

            love.graphics.setColor(1, 1, 1)
            love.graphics.draw(faceImage, x + 12, y + 11)
        end
    end

    -- etc.
end

Drawing ace

A pip is drawn in the middle of the card if it is an ace.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            local pipImage = images['pip_'..card.suit]
            
            if card.rank == 1 then
                love.graphics.draw(pipImage, x + 21, y + 31)
            end
        end
    end

    local testHand = {
        {suit = 'club', rank = 1},
        {suit = 'diamond', rank = 1},
        {suit = 'heart', rank = 1},
        {suit = 'spade', rank = 1},
    }

    for cardIndex, card in ipairs(testHand) do
        drawCard(card, (cardIndex - 1) * 60, 0)
    end
end

Drawing 2

For a rank of 2, two pips are drawn.

The first pip is in the middle on the X axis and at the top plus an offset of 7 on the Y axis.

The second pip is also in the middle on the X axis, and is mirrored on the Y axis by being offset by 7 from the bottom and drawn flipped both horizontally and vertically, similarly to the rank and suit.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            local pipImage = images['pip_'..card.suit]
            local pipWidth = 11

            if card.rank == 1 then
                love.graphics.draw(
                    pipImage,
                    x + 21,
                    y + 31
                )
            elseif card.rank == 2 then
                love.graphics.draw(
                    pipImage,
                    x + 21,
                    y + 7
                )
                love.graphics.draw(
                    pipImage,
                    x + 21 + pipWidth,
                    y + cardHeight - 7,
                    0,
                    -1
                )
            end
        end
    end

    local testHand = {
        {suit = 'club', rank = 2},
        {suit = 'diamond', rank = 2},
        {suit = 'heart', rank = 2},
        {suit = 'spade', rank = 2},
    }

    -- etc.
end

Simplifying code

Positions are reused, so they are made into variables.

Full code at this point

function love.draw()
             -- etc.

            local pipImage = images['pip_'..card.suit]
            local pipWidth = 11

            local xMid = 21
            local yTop = 7

            if card.rank == 1 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + 31
                )
            elseif card.rank == 2 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            end

            -- etc.
end

Drawing 3

Drawing a 3 is the same as drawing both an ace and a 2.

The middle of the card on the Y axis is reused, so it is made into a variable.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            local pipImage = images['pip_'..card.suit]
            local pipWidth = 11

            local xMid = 21
            local yTop = 7
            local yMid = 31

            if card.rank == 1 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yMid
                )
            elseif card.rank == 2 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            elseif card.rank == 3 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yMid
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            end
        end
    end

    local testHand = {
        {suit = 'club', rank = 3},
        {suit = 'diamond', rank = 3},
        {suit = 'heart', rank = 3},
        {suit = 'spade', rank = 3},
    }

    -- etc.
end

Drawing 4

The pips for rank 4 are in the same position on the Y axis as the pips for rank 2, with a different X offset and mirrored on the X axis.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            local pipImage = images['pip_'..card.suit]
            local pipWidth = 11

            local xLeft = 11
            local xMid = 21
            local yTop = 7
            local yMid = 31

            if card.rank == 1 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yMid
                )
            elseif card.rank == 2 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            elseif card.rank == 3 then
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yMid
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xMid + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            elseif card.rank == 4 then
                love.graphics.draw(
                    pipImage,
                    x + xLeft,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + cardWidth - xLeft - pipWidth,
                    y + yTop
                )
                love.graphics.draw(
                    pipImage,
                    x + xLeft + pipWidth,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
                love.graphics.draw(
                    pipImage,
                    x + cardWidth - xLeft,
                    y + cardHeight - yTop,
                    0,
                    -1
                )
            end
        end
    end

    local testHand = {
        {suit = 'club', rank = 4},
        {suit = 'diamond', rank = 4},
        {suit = 'heart', rank = 4},
        {suit = 'spade', rank = 4},
    }

    -- etc.
end

Simplifying code

Pips have an X and Y offset, and can be mirrored on the X and Y axes.

A function is made with the offsets and whether to mirror on the X or Y axes as parameters.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            local function drawPip(offsetX, offsetY, mirrorX, mirrorY)
                local pipImage = images['pip_'..card.suit]
                local pipWidth = 11

                love.graphics.draw(
                    pipImage,
                    x + offsetX,
                    y + offsetY
                )
                if mirrorX then
                    love.graphics.draw(
                        pipImage,
                        x + cardWidth - offsetX - pipWidth,
                        y + offsetY
                    )
                end
                if mirrorY then
                    love.graphics.draw(
                        pipImage,
                        x + offsetX + pipWidth,
                        y + cardHeight - offsetY,
                        0,
                        -1
                    )
                end
                if mirrorX and mirrorY then
                    love.graphics.draw(
                        pipImage,
                        x + cardWidth - offsetX,
                        y + cardHeight - offsetY,
                        0,
                        -1
                    )
                end
            end

            local xLeft = 11
            local xMid = 21
            local yTop = 7
            local yMid = 31

            if card.rank == 1 then
                drawPip(xMid, yMid)

            elseif card.rank == 2 then
                drawPip(xMid, yTop, false, true)

            elseif card.rank == 3 then
                drawPip(xMid, yTop, false, true)
                drawPip(xMid, yMid)

            elseif card.rank == 4 then
                drawPip(xLeft, yTop, true, true)
            end
        end
    end

    local testHand = {
        {suit = 'club', rank = 1},
        {suit = 'diamond', rank = 2},
        {suit = 'heart', rank = 3},
        {suit = 'spade', rank = 4},
    }

    -- etc.
end

Drawing the rest of the ranks

The rest of the ranks are drawn, with two new variables made for repeated Y offsets.

Full code at this point

function love.draw()
    -- etc.

    local function drawCard(card, x, y)
        -- etc.

        if card.rank > 10 then
            -- etc.
        else
            -- etc.

            local xLeft = 11
            local xMid = 21
            local yTop = 7
            local yThird = 19
            local yQtr = 23
            local yMid = 31

            if card.rank == 1 then
                drawPip(xMid, yMid)

            elseif card.rank == 2 then
                drawPip(xMid, yTop, false, true)

            elseif card.rank == 3 then
                drawPip(xMid, yTop, false, true)
                drawPip(xMid, yMid)

            elseif card.rank == 4 then
                drawPip(xLeft, yTop, true, true)

            elseif card.rank == 5 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xMid, yMid)

            elseif card.rank == 6 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xLeft, yMid, true)

            elseif card.rank == 7 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xLeft, yMid, true)
                drawPip(xMid, yThird)

            elseif card.rank == 8 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xLeft, yMid, true)
                drawPip(xMid, yThird, false, true)

            elseif card.rank == 9 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xLeft, yQtr, true, true)
                drawPip(xMid, yMid)

            elseif card.rank == 10 then
                drawPip(xLeft, yTop, true, true)
                drawPip(xLeft, yQtr, true, true)
                drawPip(xMid, 16, false, true)
            end
        end
    end

    local testHand1 = {
        {suit = 'club', rank = 1},
        {suit = 'diamond', rank = 2},
        {suit = 'heart', rank = 3},
        {suit = 'spade', rank = 4},
        {suit = 'club', rank = 5},
    }

    for cardIndex, card in ipairs(testHand1) do
        drawCard(card, (cardIndex - 1) * 60, 0)
    end

    local testHand2 = {
        {suit = 'diamond', rank = 6},
        {suit = 'heart', rank = 7},
        {suit = 'spade', rank = 8},
        {suit = 'club', rank = 9},
        {suit = 'diamond', rank = 10},
    }

    for cardIndex, card in ipairs(testHand2) do
        drawCard(card, (cardIndex - 1) * 60, 80)
    end
end

Positioning hands

The player's and dealer's hands are drawn.

They use the same space between the cards, and the same offset on the X axis, so variables are made for them.

Full code at this point

function love.draw()
    -- etc.

    local cardSpacing = 60
    local marginX = 10

    for cardIndex, card in ipairs(dealerHand) do
        drawCard(card, ((cardIndex - 1) * cardSpacing) + marginX, 30)
    end

    for cardIndex, card in ipairs(playerHand) do
        drawCard(card, ((cardIndex - 1) * cardSpacing) + marginX, 140)
    end
end

Hiding the dealer's first card

Until the round is over, the dealer's first card is hidden. Instead of drawing a blank card, the face down card image is used.

The Y position of the face down card is reused from drawing the dealer's other cards, so it is made into a variable.

Full code at this point

function love.draw()
    -- etc.

    for cardIndex, card in ipairs(dealerHand) do
        local dealerMarginY = 30
        if not roundOver and cardIndex == 1 then
            love.graphics.setColor(1, 1, 1)
            love.graphics.draw(images.card_face_down, marginX, dealerMarginY)
        else
            drawCard(card, ((cardIndex - 1) * cardSpacing) + marginX, dealerMarginY)
        end
    end

    for cardIndex, card in ipairs(playerHand) do
        drawCard(card, ((cardIndex - 1) * cardSpacing) + marginX, 140)
    end
end

Displaying totals

The total of each hand are drawn.

The dealer's total is drawn only when the round is over.

Full code at this point

function love.draw()
    -- etc.

    love.graphics.setColor(0, 0, 0)

    if roundOver then
        love.graphics.print('Total: '..getTotal(dealerHand), marginX, 10)
    else
        love.graphics.print('Total: ?', marginX, 10)
    end

    love.graphics.print('Total: '..getTotal(playerHand), marginX, 120)
end

Displaying winner

When the round is over, the result of the game is drawn.

Full code at this point

function love.draw()
    -- etc.

    if roundOver then
        local function hasHandWon(thisHand, otherHand)
            return getTotal(thisHand) <= 21
            and (
                getTotal(otherHand) > 21
                or getTotal(thisHand) > getTotal(otherHand)
            )
        end

        local function drawWinner(message)
            love.graphics.print(message, marginX, 268)
        end

        if hasHandWon(playerHand, dealerHand) then
            drawWinner('Player wins')
        elseif hasHandWon(dealerHand, playerHand) then
            drawWinner('Dealer wins')
        else
            drawWinner('Draw')
        end
    end
end

Removing text output code

The code relating to text output is removed.

Full code at this point

function love.draw()
    -- Removed:
    -- local output = {}

    -- table.insert(output, 'Player hand:')
    -- for cardIndex, card in ipairs(playerHand) do
    --     table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
    -- end
    -- table.insert(output, 'Total: '..getTotal(playerHand))

    -- table.insert(output, '')

    -- table.insert(output, 'Dealer hand:')
    -- for cardIndex, card in ipairs(dealerHand) do
    --     if not roundOver and cardIndex == 1 then
    --         table.insert(output, '(Card hidden)')
    --     else
    --         table.insert(output, 'suit: '..card.suit..', rank: '..card.rank)
    --     end
    -- end

    -- if roundOver then
    --     table.insert(output, 'Total: '..getTotal(dealerHand))
    -- else
    --     table.insert(output, 'Total: ?')
    -- end

    -- if roundOver then
    --     table.insert(output, '')

    --     local function hasHandWon(thisHand, otherHand)
    --         return getTotal(thisHand) <= 21
    --         and (
    --             getTotal(otherHand) > 21
    --             or getTotal(thisHand) > getTotal(otherHand)
    --         )
    --     end

    --     if hasHandWon(playerHand, dealerHand) then
    --         table.insert(output, 'Player wins')
    --     elseif hasHandWon(dealerHand, playerHand) then
    --         table.insert(output, 'Dealer wins')
    --     else
    --         table.insert(output, 'Draw')
    --     end
    -- end

    -- etc.
end

Drawing hit and stand buttons

The hit and stand buttons are drawn as rectangles with text on top.

Full code at this point

function love.draw()
    -- etc.

    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', 10, 230, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Hit!', 26, 236)

    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', 70, 230, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Stand', 78, 236)
end

Drawing play again button

The "play again" button is drawn occupying the same position as the other buttons, since it will only be visible when the round is over.

Full code at this point

function love.draw()
    -- etc.

    --[[
    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', 10, 230, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Hit!', 16, 236)

    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', 70, 230, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Stand', 78, 236)
    --]]

    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', 10, 230, 113, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Play again', 34, 236)
end

Basing text position on button position

The X and Y positions of the button text is based on the X and Y positions of the buttons plus an offset.

Each button's X position is used twice, so they are made into variables.

The same Y position is used by all buttons, so it is made into a variable.

Full code at this point

function love.draw()
    -- etc.

    local buttonY = 230

    local buttonHitX = 10
    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', buttonHitX, buttonY, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Hit!', buttonHitX + 16, buttonY + 6)

    local buttonStandX = 70
    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', buttonStandX, buttonY, 53, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Stand', buttonStandX + 8, buttonY + 6)

    --[[
    local buttonPlayAgainX = 10
    love.graphics.setColor(1, .5, .2)
    love.graphics.rectangle('fill', buttonPlayAgainX, buttonY, 113, 25)
    love.graphics.setColor(1, 1, 1)
    love.graphics.print('Play again', buttonPlayAgainX + 24, buttonY + 6)
    --]]
end

Simplifying code

The only difference between the code for drawing each button is the button's text, X position, width, and text offset on the X axis. A function is made with these as parameters.

Full code at this point

function love.draw()
    -- etc.

    local function drawButton(text, buttonX, buttonWidth, textOffsetX)
        local buttonY = 230
        love.graphics.setColor(1, .5, .2)
        love.graphics.rectangle('fill', buttonX, buttonY, buttonWidth, 25)
        love.graphics.setColor(1, 1, 1)
        love.graphics.print(text, buttonX + textOffsetX, buttonY + 6)
    end

    drawButton('Hit!', 10, 53, 16)
    drawButton('Stand', 70, 53, 8)
    -- drawButton('Play again', 10, 113, 24)
end

Highlighting button when cursor is over it

The color of the rectangle changes when the mouse cursor is over it.

The cursor is over the button if...

The button height is reused from drawing the button, so a variable is made.

Full code at this point

function love.draw()
    -- etc.

    local function drawButton(text, buttonX, buttonWidth, textOffsetX)
        local buttonY = 230
        local buttonHeight = 25

        if love.mouse.getX() >= buttonX
        and love.mouse.getX() < buttonX + buttonWidth
        and love.mouse.getY() >= buttonY
        and love.mouse.getY() < buttonY + buttonHeight then
            love.graphics.setColor(1, .8, .3)
        else
            love.graphics.setColor(1, .5, .2)
        end
        love.graphics.rectangle('fill', buttonX, buttonY, buttonWidth, buttonHeight)
        love.graphics.setColor(1, 1, 1)
        love.graphics.print(text, buttonX + textOffsetX, buttonY + 6)
    end

    -- etc.
end

Clicking buttons

If a mouse button is released and the mouse is over a button, then the button has been clicked, and (for now) the name of the button is printed.

Checking if the mouse is over a button is reused from drawing the button, so it is made into a function and moved into love.load.

Full code at this point

function love.load()
    -- etc.

    function isMouseInButton(buttonX, buttonWidth)
        local buttonY = 230
        local buttonHeight = 25

        return love.mouse.getX() >= buttonX
        and love.mouse.getX() < buttonX + buttonWidth
        and love.mouse.getY() >= buttonY
        and love.mouse.getY() < buttonY + buttonHeight
    end
end

function love.mousereleased()
    if isMouseInButton(10, 53) then
        print('Hit!')
    elseif isMouseInButton(70, 53) then
        print('Stand')
    --elseif isMouseInButton(10, 113) then
    --    print('Play again')
    end
end

function love.draw()
    -- etc.

    local function drawButton(text, buttonX, buttonWidth, textOffsetX)
        local buttonY = 230
        local buttonHeight = 25

        if isMouseInButton(buttonX, buttonWidth) then
            love.graphics.setColor(1, .8, .3)
        else
            love.graphics.setColor(1, .5, .2)
        end
        -- etc.
    end

    -- etc.
end

Button tables

So that the information for each button is stored in one place, a table is created for each button.

Full code at this point

function love.load()
    -- etc.

    local buttonY = 230
    local buttonHeight = 25
    local textOffsetY = 6

    buttonHit = {
        x = 10,
        y = buttonY,
        width = 53,
        height = buttonHeight,
        text = 'Hit!',
        textOffsetX = 16,
        textOffsetY = textOffsetY,
    }

    buttonStand = {
        x = 70,
        y = buttonY,
        width = 53,
        height = buttonHeight,
        text = 'Stand',
        textOffsetX = 8,
        textOffsetY = textOffsetY,
    }

    buttonPlayAgain = {
        x = 10,
        y = buttonY,
        width = 113,
        height = buttonHeight,
        text = 'Play again',
        textOffsetX = 24,
        textOffsetY = textOffsetY,
    }

    function isMouseInButton(button)
        -- Removed: local buttonY = 230
        -- Removed: local buttonHeight = 25

        return love.mouse.getX() >= button.x
        and love.mouse.getX() < button.x + button.width
        and love.mouse.getY() >= button.y
        and love.mouse.getY() < button.y + button.height
    end
end

function love.mousereleased()
    if isMouseInButton(buttonHit) then
        print('Hit!')
    elseif isMouseInButton(buttonStand) then
        print('Stand')
    --elseif isMouseInButton(buttonPlayAgain) then
    --    print('Play again')
    end
end

function love.draw()
    -- etc.

    local function drawButton(button)
        -- Removed: local buttonY = 230
        -- Removed: local buttonHeight = 25

        if isMouseInButton(button) then
            love.graphics.setColor(1, .8, .3)
        else
            love.graphics.setColor(1, .5, .2)
        end

        love.graphics.rectangle('fill', button.x, button.y, button.width, button.height)
        love.graphics.setColor(1, 1, 1)
        love.graphics.print(button.text, button.x + button.textOffsetX, button.y + button.textOffsetY)
    end

    drawButton(buttonHit)
    drawButton(buttonStand)
    -- drawButton(buttonPlayAgain)
end

Showing play again button after round is over

The hit and stand buttons are shown while the round is in progress, and the play again button is shown when the round is over.

Full code at this point

function love.draw()
    -- etc.

    if not roundOver then
        drawButton(buttonHit)
        drawButton(buttonStand)
    else
        drawButton(buttonPlayAgain)
    end
end

Using buttons

The code from love.keypressed is moved to love.mousepressed, and instead of using the keys it uses the on-screen buttons.

Full code at this point

function love.mousereleased()
    if not roundOver then
        if isMouseInButton(buttonHit) then
            takeCard(playerHand)
            if getTotal(playerHand) >= 21 then
                roundOver = true
            end
        elseif isMouseInButton(buttonStand) then
            roundOver = true
        end

        if roundOver then
            while getTotal(dealerHand) < 17 do
                takeCard(dealerHand)
            end
        end
    elseif isMouseInButton(buttonPlayAgain) then
        love.load()
    end
end

Resetting the game

Only some variables need to be reset when the play again button is clicked, so a function is made.

Full code at this point

function love.load()
    -- etc.

    function reset()
        deck = {}
        for suitIndex, suit in ipairs({'club', 'diamond', 'heart', 'spade'}) do
            for rank = 1, 13 do
                table.insert(deck, {suit = suit, rank = rank})
            end
        end

        playerHand = {}
        takeCard(playerHand)
        takeCard(playerHand)

        dealerHand = {}
        takeCard(dealerHand)
        takeCard(dealerHand)

        roundOver = false
    end

    reset()
end

function love.mousereleased()
    -- etc.

    elseif isMouseInButton(buttonPlayAgain) then
        reset()
    end
end