Build a Deck-Building Card Game in LOVE2D
Build a data-driven card game engine with zones, drag-and-drop, and combat.
Introduction
30 Second Summary
Every card game has a hidden engine running behind the scenes. Something decides where cards go, tracks what resources you have, and enforces the rules.
In this project, you will build a data-driven card game engine in LOVE2D. You will use it to create "Dino Hunt," a deck-building game where you play tribe cards, gather resources, and fight dinosaurs.
What You'll Build
A 2D card game engine in Lua that handles card rendering, zone management, drag-and-drop input, and a turn-based game loop. All game-specific data lives in a separate configuration file.
By the end of this project, you'll have:
- A reusable card game engine that cleanly separates engine code from game data definitions.
- A drag-and-drop card system with zone snapping for deck, hand, discard, and tableau layouts.
- A turn-based game loop with resource tracking, deck cycling, and creature combat.
- A card market where you spend resources to buy new cards into your deck, completing the full deck-building loop (Secret Mission).
Are there any prerequisites?
This project assumes you know basic Lua syntax and are familiar with LOVE2D callbacks like love.load, love.update, and love.draw. You should also have LOVE2D 11.5 installed on your machine.
Project Setup and Window Configuration
Every LOVE2D game starts with two foundational files. conf.lua configures the game window before anything else runs. main.lua contains the game loop callbacks that LOVE2D calls every frame.
In this step, you will create the project folder, configure a 1280x800 game window, and set up the six LOVE2D callbacks your card game engine needs.
In this step, get ready to:
- Create the project folder and window configuration file.
- Set up the main game file with LOVE2D callbacks.
- Run the game to verify the window opens correctly.
Create the project folder and conf.lua
LOVE2D reads a file called conf.lua before the game starts. This is where you set the window size, title, and engine version so your game window is ready from the first frame.
- Open a terminal by pressing Ctrl+Alt+T.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir board-game-engine
cd board-game-engine
- Confirm the folder was created by running:
ls ~/Desktop
You should see board-game-engine in the output.
- Open the project folder in VS Code by running:
code .
VS Code didn't open?
If your terminal says command not found, VS Code may not be in your system PATH. Open VS Code manually, press Ctrl+Shift+P, type Shell Command: Install 'code' command in PATH, and press Enter. Then try again.
Help me get VS Code's code command working on Linux
VS Code opens with the board-game-engine folder loaded in the Explorer sidebar on the left. Now you can create files directly in this folder.
- In the Explorer sidebar, hover over the BOARD-GAME-ENGINE folder name.
- Click the New File icon that appears.
- Type conf.lua and press Enter.
- Copy the following code into conf.lua:
function love.conf(t)
t.title = "Board Game Engine"
t.window.width = 1280
t.window.height = 800
t.version = "11.5"
end
What does this code do?
LOVE2D calls love.conf(t) automatically before your game starts. The t table holds all the engine's default settings, and you override the ones you care about.
- t.title sets the text displayed in the window's title bar.
- t.window.width and t.window.height set the window to 1280x800 pixels, giving you enough space for cards, zones, and UI panels.
- t.version tells LOVE2D which API version (11.5) your code targets.
- Save conf.lua by pressing Ctrl+S.
You won't see a change yet. LOVE2D needs main.lua before it can run.
Awesome, I've got everything!
Double check that you have saved conf.lua.
I'd like to double check the full code
function love.conf(t)
t.title = "Board Game Engine"
t.window.width = 1280
t.window.height = 800
t.version = "11.5"
end
Create the main.lua skeleton
LOVE2D's game loop is built around callbacks. You define functions like love.load(), love.update(), and love.draw(), and LOVE2D calls them for you at the right time every frame.
- In the Explorer sidebar, hover over the BOARD-GAME-ENGINE folder name.
- Click the New File icon.
- Type main.lua and press Enter.
- Copy the following code into main.lua:
function love.load()
end
function love.update(dt)
end
function love.draw()
love.graphics.print("Board Game Engine", 10, 10)
end
function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
end
function love.keypressed(key)
end
What does this code do?
Each function is a LOVE2D callback. You define them, and LOVE2D calls them automatically.
- love.load() runs once at startup. You will initialize game state here.
- love.update(dt) runs every frame. The dt parameter is the time since the last frame in seconds.
- love.draw() runs every frame after update. All rendering happens here.
- love.mousepressed(), love.mousereleased(), and love.keypressed() fire when the player interacts with the mouse or keyboard.
- love.graphics.print("Board Game Engine", 10, 10) draws text at pixel position (10, 10) so you can confirm the game runs.
- Save main.lua by pressing Ctrl+S.
Awesome, I've got everything!
Double check that you have saved main.lua.
I'd like to double check the full code
function love.load()
end
function love.update(dt)
end
function love.draw()
love.graphics.print("Board Game Engine", 10, 10)
end
function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
end
function love.keypressed(key)
end
Run and verify the game window
Both files are in place. Time to confirm LOVE2D can read them and open your game window.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Make sure you are inside the board-game-engine folder. If not, run:
cd ~/Desktop/board-game-engine
- Launch the game by running:
love .
A 1280x800 window opens with the title "Board Game Engine" in the title bar. You should see the text "Board Game Engine" in the top-left corner of the window.
What does love . do?
The love command launches the LOVE2D engine. The . tells it to load the game from the current directory. LOVE2D looks for conf.lua first, then runs main.lua.
Window didn't open?
- If you see love: command not found, LOVE2D is not in your system PATH. Verify it is installed by checking your package manager (e.g. sudo apt install love on Ubuntu/Debian).
- If you see a LOVE2D error screen instead of your text, check that main.lua is saved inside the board-game-engine folder, not in a subfolder or on the Desktop itself.
- If the window opens but you do not see any text, make sure you saved main.lua after pasting the code. Unsaved changes show a dot next to the filename in VS Code's tab bar.
Help me troubleshoot why my LOVE2D game window is not opening correctly
- Close the game window when you are done by pressing the window's close button or pressing Ctrl+C in the terminal.
Your game window is alive. Next up, you will build the card data system and fill that window with a deck of cards, a hand zone, and a row of dinosaur creatures.
Card Data System and Zone Rendering
Your window is open and ready. Now you need something to actually display in it.
Every card game needs two things: the data that describes the cards, and the systems that draw them on screen. In this step, you will build both. You will create a config file that defines every card, creature, and resource in the game. Then you will build a reusable engine that knows how to render cards and manage zones like the deck, hand, and tableau.
In this step, get ready to:
- Define all card, creature, and resource data in a separate config file.
- Build an engine module with card rendering, zone management, and layout functions.
- Wire the engine and config into main.lua to display a deck, hand, and creatures on screen.
Create the game config file
The core idea behind this project is data-driven architecture. All the game-specific details (card names, stats, creatures, resources) live in one file. The engine that renders and manages them lives in another. This means you could swap the config for a completely different card game without touching the engine code.
- Create a new file called game_config.lua inside your board-game-engine folder.
- Add the game header, resource definitions, and card definitions by pasting this code:
local config = {}
config.title = "Dino Hunt"
config.hand_size = 5
-- Three resource types, each with a cap of 6
config.resources = {
{ name = "Food", key = "food", icon = "F", color = {0.85, 0.65, 0.13}, max = 6, start = 2 },
{ name = "Meeples", key = "meeples", icon = "M", color = {0.18, 0.80, 0.44}, max = 6, start = 2 },
{ name = "Power", key = "power", icon = "P", color = {0.91, 0.30, 0.24}, max = 6, start = 0 },
}
-- Card definitions keyed by ID: tribe cards and action cards
config.card_defs = {
hunter = { name = "Hunter", type = "tribe", strength = 2, food_gain = 1, color = {0.55, 0.55, 0.60}, description = "+1 Food | 2 Str" },
gatherer = { name = "Gatherer", type = "tribe", strength = 1, food_gain = 2, color = {0.55, 0.55, 0.60}, description = "+2 Food | 1 Str" },
elder = { name = "Elder", type = "tribe", strength = 0, meeple_gain = 1, color = {0.55, 0.55, 0.60}, description = "+1 Meeple" },
warrior = { name = "Warrior", type = "tribe", strength = 3, color = {0.55, 0.55, 0.60}, description = "3 Strength" },
totem = { name = "Totem", type = "action", cost = 3, cost_type = "food", power_gain = 2, persistent = true, color = {0.55, 0.36, 0.17}, description = "+2 Pwr/turn" },
trap = { name = "Dino Trap", type = "action", cost = 2, cost_type = "food", power_gain = 1, food_gain = 1, color = {0.55, 0.36, 0.17}, description = "+1 Pwr +1 Food" },
tracker = { name = "Tracker", type = "tribe", cost = 2, cost_type = "meeples", strength = 2, food_gain = 2, color = {0.45, 0.45, 0.55}, description = "+2 Food | 2 Str" },
shaman = { name = "Shaman", type = "tribe", cost = 3, cost_type = "meeples", strength = 1, power_gain = 2, color = {0.45, 0.45, 0.55}, description = "+2 Pwr | 1 Str" },
}
What does this code do?
- config.resources defines three resource types the player can earn and spend: Food, Meeples, and Power. Each has a max cap of 6.
- config.card_defs is a table keyed by card ID. Each card has a name, type, stat values (strength, resource gains), a color for rendering, and a short description.
- Tribe cards provide combat strength and resources. Action cards like Totem and Dino Trap cost resources to play but give power.
You won't see a change yet. The next chunk adds the creatures and deck lists that complete the config.
- Add the creature definitions, starter deck, market deck, and the return statement below config.card_defs:
-- Dinosaur creatures the player must defeat to score points
config.creature_defs = {
{ id = "raptor", name = "Raptor", hp = 4, points = 2, reward_food = 2, color = {0.30, 0.69, 0.31} },
{ id = "stego", name = "Stegosaur", hp = 6, points = 3, reward_food = 2, color = {0.25, 0.47, 0.85} },
{ id = "ptero", name = "Pterodactyl", hp = 5, points = 3, reward_food = 1, color = {0.61, 0.35, 0.71} },
{ id = "trex", name = "T-Rex", hp = 8, points = 5, reward_food = 3, color = {0.83, 0.18, 0.18} },
{ id = "trice", name = "Triceratops", hp = 7, points = 4, reward_food = 2, color = {0.80, 0.60, 0.20} },
{ id = "bronto", name = "Brontosaur", hp = 10, points = 7, reward_food = 4, color = {0.40, 0.60, 0.40} },
}
-- The 8 cards every player starts with
config.starter_deck = { "hunter", "hunter", "hunter", "gatherer", "gatherer", "elder", "elder", "warrior" }
-- Cards available in the market for later purchasing
config.market_deck = { "tracker", "tracker", "tracker", "shaman", "shaman", "totem", "totem", "trap", "trap", "trap", "warrior", "warrior" }
return config
What does this code do?
- config.creature_defs is an array of dinosaurs the player fights. Each has HP (how much attack you need to defeat it), points (score reward), and a food reward.
- config.starter_deck lists the 8 card IDs the player starts with. These IDs match keys in card_defs.
- config.market_deck holds stronger cards available for purchase later. You will use this in a future step.
- return config at the bottom is critical. It makes the table available when another file calls require("game_config").
- Save game_config.lua.
Why separate the data from the engine?
This config file contains zero rendering logic. It only describes what exists in the game. The engine you build next only knows how to render and manage cards. It does not know anything about dinosaurs or food resources. This separation means you could replace game_config.lua with a config for a pirate card game or a space trading game, and the engine would render it without a single code change.
Awesome, I've got everything!
Make sure you have saved game_config.lua.
I'd like to double check the full code
local config = {}
config.title = "Dino Hunt"
config.hand_size = 5
config.resources = {
{ name = "Food", key = "food", icon = "F", color = {0.85, 0.65, 0.13}, max = 6, start = 2 },
{ name = "Meeples", key = "meeples", icon = "M", color = {0.18, 0.80, 0.44}, max = 6, start = 2 },
{ name = "Power", key = "power", icon = "P", color = {0.91, 0.30, 0.24}, max = 6, start = 0 },
}
config.card_defs = {
hunter = { name = "Hunter", type = "tribe", strength = 2, food_gain = 1, color = {0.55, 0.55, 0.60}, description = "+1 Food | 2 Str" },
gatherer = { name = "Gatherer", type = "tribe", strength = 1, food_gain = 2, color = {0.55, 0.55, 0.60}, description = "+2 Food | 1 Str" },
elder = { name = "Elder", type = "tribe", strength = 0, meeple_gain = 1, color = {0.55, 0.55, 0.60}, description = "+1 Meeple" },
warrior = { name = "Warrior", type = "tribe", strength = 3, color = {0.55, 0.55, 0.60}, description = "3 Strength" },
totem = { name = "Totem", type = "action", cost = 3, cost_type = "food", power_gain = 2, persistent = true, color = {0.55, 0.36, 0.17}, description = "+2 Pwr/turn" },
trap = { name = "Dino Trap", type = "action", cost = 2, cost_type = "food", power_gain = 1, food_gain = 1, color = {0.55, 0.36, 0.17}, description = "+1 Pwr +1 Food" },
tracker = { name = "Tracker", type = "tribe", cost = 2, cost_type = "meeples", strength = 2, food_gain = 2, color = {0.45, 0.45, 0.55}, description = "+2 Food | 2 Str" },
shaman = { name = "Shaman", type = "tribe", cost = 3, cost_type = "meeples", strength = 1, power_gain = 2, color = {0.45, 0.45, 0.55}, description = "+2 Pwr | 1 Str" },
}
config.creature_defs = {
{ id = "raptor", name = "Raptor", hp = 4, points = 2, reward_food = 2, color = {0.30, 0.69, 0.31} },
{ id = "stego", name = "Stegosaur", hp = 6, points = 3, reward_food = 2, color = {0.25, 0.47, 0.85} },
{ id = "ptero", name = "Pterodactyl", hp = 5, points = 3, reward_food = 1, color = {0.61, 0.35, 0.71} },
{ id = "trex", name = "T-Rex", hp = 8, points = 5, reward_food = 3, color = {0.83, 0.18, 0.18} },
{ id = "trice", name = "Triceratops", hp = 7, points = 4, reward_food = 2, color = {0.80, 0.60, 0.20} },
{ id = "bronto", name = "Brontosaur", hp = 10, points = 7, reward_food = 4, color = {0.40, 0.60, 0.40} },
}
config.starter_deck = { "hunter", "hunter", "hunter", "gatherer", "gatherer", "elder", "elder", "warrior" }
config.market_deck = { "tracker", "tracker", "tracker", "shaman", "shaman", "totem", "totem", "trap", "trap", "trap", "warrior", "warrior" }
return config
Create the engine module
The engine is the reusable core of your card game. It does not know what a "Hunter" or "Raptor" is. It only knows how to create card objects, arrange them in zones, and draw them on screen.
- Create a new file called engine.lua inside your board-game-engine folder.
- Add the module header and the three constructor functions by pasting this code:
local engine = {}
-- Standard card dimensions used for rendering and hit detection
engine.CARD_W = 90
engine.CARD_H = 130
-- Create a card instance from a definition table
function engine.newCard(id, def)
return {
id = id,
name = def.name or id,
type = def.type or "tribe",
strength = def.strength or 0,
cost = def.cost or 0,
cost_type = def.cost_type or "",
food_gain = def.food_gain or 0,
meeple_gain = def.meeple_gain or 0,
power_gain = def.power_gain or 0,
persistent = def.persistent or false,
description = def.description or "",
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
face_up = true,
}
end
-- Create a creature instance from a creature definition
function engine.newCreature(def)
return {
id = def.id,
name = def.name,
type = "creature",
hp = def.hp,
points = def.points,
reward_food = def.reward_food or 0,
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
defeated = false,
}
end
-- Create a zone that holds and arranges cards
function engine.newZone(x, y, w, h, layout, label)
return {
x = x, y = y, w = w, h = h,
layout = layout or "row",
label = label or "",
cards = {},
}
end
What does this code do?
- engine.newCard(id, def) takes a card ID and a definition from the config, then returns a card object with all the stats plus x/y position and a face_up flag. The or fallbacks ensure missing fields default to safe values.
- engine.newCreature(def) is similar but for dinosaur creatures. It sets type = "creature" so the renderer can distinguish creatures from regular cards.
- engine.newZone(x, y, w, h, layout, label) creates a rectangular container that holds cards. The layout field controls how cards are arranged inside: "stack" piles them up, "fan" and "row" spread them out.
You won't see a change yet. The next chunk adds the zone management functions that position and move cards between zones.
- Add the zone management functions below engine.newZone:
-- Insert a card into a zone and recalculate positions
function engine.addCard(zone, card)
table.insert(zone.cards, card)
engine.layoutZone(zone)
end
-- Remove a specific card from a zone
function engine.removeCard(zone, card)
for i, c in ipairs(zone.cards) do
if c == card then
table.remove(zone.cards, i)
engine.layoutZone(zone)
return true
end
end
return false
end
-- Position all cards in a zone based on its layout type
function engine.layoutZone(zone)
local count = #zone.cards
if count == 0 then return end
local W, H = engine.CARD_W, engine.CARD_H
if zone.layout == "stack" then
-- Stack: center all cards on the same spot
for _, card in ipairs(zone.cards) do
card.x = zone.x + (zone.w - W) / 2
card.y = zone.y + (zone.h - H) / 2
end
else
-- Fan/Row: spread cards evenly with smart spacing
local max_spacing = W + 10
local spacing = math.min(max_spacing, (zone.w - W) / math.max(count - 1, 1))
local total = (count - 1) * spacing + W
local sx = zone.x + (zone.w - total) / 2
for i, card in ipairs(zone.cards) do
card.x = sx + (i - 1) * spacing
card.y = zone.y + (zone.h - H) / 2
end
end
end
What does this code do?
- engine.addCard and engine.removeCard handle inserting and removing cards from a zone. Both automatically re-layout the zone afterward.
- engine.layoutZone is the layout engine. For "stack" zones (like the deck), all cards sit in the center on top of each other. For "fan" or "row" zones (like the hand), cards spread out evenly. The spacing math ensures cards overlap gracefully when the zone gets crowded.
You won't see a change yet. The next chunk adds card movement and utility functions.
- Add the card movement and utility functions below engine.layoutZone:
-- Fisher-Yates shuffle to randomize cards in a zone
function engine.shuffle(zone)
local cards = zone.cards
for i = #cards, 2, -1 do
local j = love.math.random(1, i)
cards[i], cards[j] = cards[j], cards[i]
end
engine.layoutZone(zone)
end
-- Deal a number of cards from one zone to another
function engine.dealCards(from, to, count)
local dealt = 0
for i = 1, count do
if #from.cards == 0 then break end
local card = table.remove(from.cards, #from.cards)
card.face_up = true
table.insert(to.cards, card)
dealt = dealt + 1
end
engine.layoutZone(from)
engine.layoutZone(to)
return dealt
end
-- Move every card from one zone to another
function engine.moveAllCards(from, to)
for _, card in ipairs(from.cards) do
table.insert(to.cards, card)
end
from.cards = {}
engine.layoutZone(from)
engine.layoutZone(to)
end
-- Check if a point is inside a rectangle
function engine.pointInRect(px, py, rx, ry, rw, rh)
return px >= rx and px <= rx + rw and py >= ry and py <= ry + rh
end
-- Find the topmost card at a point in a zone (iterates in reverse for z-order)
function engine.findCardInZone(zone, px, py)
local W, H = engine.CARD_W, engine.CARD_H
for i = #zone.cards, 1, -1 do
local c = zone.cards[i]
if engine.pointInRect(px, py, c.x, c.y, W, H) then
return c, i
end
end
return nil, nil
end
What does this code do?
- engine.shuffle uses the Fisher-Yates algorithm to randomize cards in place. It swaps each card with a random card before it, producing a fair shuffle.
- engine.dealCards pops cards off the top of one zone and inserts them into another. Each dealt card is flipped face-up.
- engine.pointInRect and engine.findCardInZone are hit-testing utilities you will use for drag-and-drop in the next step. findCardInZone iterates in reverse so the topmost rendered card is found first.
You won't see a change yet. The next chunk adds card rendering.
- Add the card rendering function below engine.findCardInZone:
-- Render a single card as a rounded rectangle with text overlays
function engine.drawCard(card, dragging)
local W, H = engine.CARD_W, engine.CARD_H
local cx, cy = card.x, card.y
-- Draw a shadow behind dragged cards
if dragging then
love.graphics.setColor(0, 0, 0, 0.3)
love.graphics.rectangle("fill", cx + 4, cy + 4, W, H, 6, 6)
end
-- Card background
love.graphics.setColor(card.color[1], card.color[2], card.color[3])
love.graphics.rectangle("fill", cx, cy, W, H, 6, 6)
-- Card border
love.graphics.setColor(0.2, 0.2, 0.2)
love.graphics.rectangle("line", cx, cy, W, H, 6, 6)
if card.face_up ~= false then
if card.type == "creature" then
-- Creature card layout: name, HP, points, food reward
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(1, 0.9, 0.2)
love.graphics.printf("HP: " .. card.hp, cx + 4, cy + 40, W - 8, "center")
love.graphics.setColor(0.6, 1, 0.6)
love.graphics.printf(card.points .. " pts", cx + 4, cy + 60, W - 8, "center")
love.graphics.setColor(0.8, 0.8, 0.9)
love.graphics.printf("+" .. card.reward_food .. " Food", cx + 4, cy + 85, W - 8, "center")
-- Red X overlay for defeated creatures
if card.defeated then
love.graphics.setColor(1, 0, 0, 0.6)
love.graphics.setLineWidth(3)
love.graphics.line(cx + 10, cy + 10, cx + W - 10, cy + H - 10)
love.graphics.line(cx + W - 10, cy + 10, cx + 10, cy + H - 10)
love.graphics.setLineWidth(1)
end
else
-- Regular card layout: name, type, strength, description, cost
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(0.85, 0.85, 0.9, 0.7)
love.graphics.printf(card.type, cx + 4, cy + 26, W - 8, "center")
if card.strength > 0 then
love.graphics.setColor(1, 0.85, 0.2)
love.graphics.printf("Str " .. card.strength, cx + 4, cy + 50, W - 8, "center")
end
love.graphics.setColor(0.9, 0.9, 0.9)
love.graphics.printf(card.description, cx + 4, cy + H - 42, W - 8, "center")
if card.cost > 0 and card.cost_type ~= "" then
love.graphics.setColor(1, 0.8, 0.2)
local ct = card.cost_type:sub(1, 1):upper()
love.graphics.printf(card.cost .. ct, cx + 4, cy + H - 18, W - 8, "right")
end
end
else
-- Face-down card: patterned back
love.graphics.setColor(0.25, 0.25, 0.4)
love.graphics.rectangle("fill", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
love.graphics.setColor(0.35, 0.35, 0.55)
love.graphics.rectangle("line", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
end
end
What does this code do?
- engine.drawCard is the main card renderer. It uses love.graphics.rectangle for the card body and love.graphics.printf for centered text. The 6-pixel radius on each rectangle creates rounded corners.
- The function branches based on card type. Creatures show HP and points. Regular cards show name, type, strength, description, and cost. Face-down cards show a simple patterned back.
- The dragging parameter adds a shadow offset behind the card. You will use this in the next step for drag-and-drop.
You won't see a change yet. The final engine chunk adds zone rendering.
- Add the zone rendering function and the return statement below engine.drawCard:
-- Render a zone background, label, and all contained cards
function engine.drawZone(zone, skip_card)
-- Zone background
love.graphics.setColor(0.12, 0.12, 0.18, 0.6)
love.graphics.rectangle("fill", zone.x, zone.y, zone.w, zone.h, 8, 8)
-- Zone border
love.graphics.setColor(0.35, 0.35, 0.45, 0.8)
love.graphics.rectangle("line", zone.x, zone.y, zone.w, zone.h, 8, 8)
-- Zone label above the box
love.graphics.setColor(0.5, 0.5, 0.6)
love.graphics.print(zone.label, zone.x + 5, zone.y - 16)
if zone.layout == "stack" then
-- Only render the top card plus a count indicator
if #zone.cards > 0 then
local top = zone.cards[#zone.cards]
if top ~= skip_card then
engine.drawCard(top, false)
end
love.graphics.setColor(1, 1, 1, 0.7)
love.graphics.printf(tostring(#zone.cards), zone.x, zone.y + zone.h - 22, zone.w, "center")
end
else
-- Render all cards in the zone
for _, card in ipairs(zone.cards) do
if card ~= skip_card then
engine.drawCard(card, false)
end
end
end
end
return engine
What does this code do?
- engine.drawZone renders a zone as a semi-transparent rounded rectangle with a label above it.
- For "stack" zones (the deck and discard pile), only the top card renders. A count indicator shows how many cards are in the pile.
- For all other layouts, every card in the zone renders. The skip_card parameter lets you exclude a card that is currently being dragged, preventing it from drawing in two places at once.
- return engine at the bottom makes the module available to main.lua via require("engine").
- Save engine.lua.
Awesome, I've got everything!
Make sure you have saved engine.lua.
I'd like to double check the full code
local engine = {}
engine.CARD_W = 90
engine.CARD_H = 130
function engine.newCard(id, def)
return {
id = id,
name = def.name or id,
type = def.type or "tribe",
strength = def.strength or 0,
cost = def.cost or 0,
cost_type = def.cost_type or "",
food_gain = def.food_gain or 0,
meeple_gain = def.meeple_gain or 0,
power_gain = def.power_gain or 0,
persistent = def.persistent or false,
description = def.description or "",
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
face_up = true,
}
end
function engine.newCreature(def)
return {
id = def.id,
name = def.name,
type = "creature",
hp = def.hp,
points = def.points,
reward_food = def.reward_food or 0,
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
defeated = false,
}
end
function engine.newZone(x, y, w, h, layout, label)
return {
x = x, y = y, w = w, h = h,
layout = layout or "row",
label = label or "",
cards = {},
}
end
function engine.addCard(zone, card)
table.insert(zone.cards, card)
engine.layoutZone(zone)
end
function engine.removeCard(zone, card)
for i, c in ipairs(zone.cards) do
if c == card then
table.remove(zone.cards, i)
engine.layoutZone(zone)
return true
end
end
return false
end
function engine.layoutZone(zone)
local count = #zone.cards
if count == 0 then return end
local W, H = engine.CARD_W, engine.CARD_H
if zone.layout == "stack" then
for _, card in ipairs(zone.cards) do
card.x = zone.x + (zone.w - W) / 2
card.y = zone.y + (zone.h - H) / 2
end
else
local max_spacing = W + 10
local spacing = math.min(max_spacing, (zone.w - W) / math.max(count - 1, 1))
local total = (count - 1) * spacing + W
local sx = zone.x + (zone.w - total) / 2
for i, card in ipairs(zone.cards) do
card.x = sx + (i - 1) * spacing
card.y = zone.y + (zone.h - H) / 2
end
end
end
function engine.shuffle(zone)
local cards = zone.cards
for i = #cards, 2, -1 do
local j = love.math.random(1, i)
cards[i], cards[j] = cards[j], cards[i]
end
engine.layoutZone(zone)
end
function engine.dealCards(from, to, count)
local dealt = 0
for i = 1, count do
if #from.cards == 0 then break end
local card = table.remove(from.cards, #from.cards)
card.face_up = true
table.insert(to.cards, card)
dealt = dealt + 1
end
engine.layoutZone(from)
engine.layoutZone(to)
return dealt
end
function engine.moveAllCards(from, to)
for _, card in ipairs(from.cards) do
table.insert(to.cards, card)
end
from.cards = {}
engine.layoutZone(from)
engine.layoutZone(to)
end
function engine.pointInRect(px, py, rx, ry, rw, rh)
return px >= rx and px <= rx + rw and py >= ry and py <= ry + rh
end
function engine.findCardInZone(zone, px, py)
local W, H = engine.CARD_W, engine.CARD_H
for i = #zone.cards, 1, -1 do
local c = zone.cards[i]
if engine.pointInRect(px, py, c.x, c.y, W, H) then
return c, i
end
end
return nil, nil
end
function engine.drawCard(card, dragging)
local W, H = engine.CARD_W, engine.CARD_H
local cx, cy = card.x, card.y
if dragging then
love.graphics.setColor(0, 0, 0, 0.3)
love.graphics.rectangle("fill", cx + 4, cy + 4, W, H, 6, 6)
end
love.graphics.setColor(card.color[1], card.color[2], card.color[3])
love.graphics.rectangle("fill", cx, cy, W, H, 6, 6)
love.graphics.setColor(0.2, 0.2, 0.2)
love.graphics.rectangle("line", cx, cy, W, H, 6, 6)
if card.face_up ~= false then
if card.type == "creature" then
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(1, 0.9, 0.2)
love.graphics.printf("HP: " .. card.hp, cx + 4, cy + 40, W - 8, "center")
love.graphics.setColor(0.6, 1, 0.6)
love.graphics.printf(card.points .. " pts", cx + 4, cy + 60, W - 8, "center")
love.graphics.setColor(0.8, 0.8, 0.9)
love.graphics.printf("+" .. card.reward_food .. " Food", cx + 4, cy + 85, W - 8, "center")
if card.defeated then
love.graphics.setColor(1, 0, 0, 0.6)
love.graphics.setLineWidth(3)
love.graphics.line(cx + 10, cy + 10, cx + W - 10, cy + H - 10)
love.graphics.line(cx + W - 10, cy + 10, cx + 10, cy + H - 10)
love.graphics.setLineWidth(1)
end
else
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(0.85, 0.85, 0.9, 0.7)
love.graphics.printf(card.type, cx + 4, cy + 26, W - 8, "center")
if card.strength > 0 then
love.graphics.setColor(1, 0.85, 0.2)
love.graphics.printf("Str " .. card.strength, cx + 4, cy + 50, W - 8, "center")
end
love.graphics.setColor(0.9, 0.9, 0.9)
love.graphics.printf(card.description, cx + 4, cy + H - 42, W - 8, "center")
if card.cost > 0 and card.cost_type ~= "" then
love.graphics.setColor(1, 0.8, 0.2)
local ct = card.cost_type:sub(1, 1):upper()
love.graphics.printf(card.cost .. ct, cx + 4, cy + H - 18, W - 8, "right")
end
end
else
love.graphics.setColor(0.25, 0.25, 0.4)
love.graphics.rectangle("fill", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
love.graphics.setColor(0.35, 0.35, 0.55)
love.graphics.rectangle("line", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
end
end
function engine.drawZone(zone, skip_card)
love.graphics.setColor(0.12, 0.12, 0.18, 0.6)
love.graphics.rectangle("fill", zone.x, zone.y, zone.w, zone.h, 8, 8)
love.graphics.setColor(0.35, 0.35, 0.45, 0.8)
love.graphics.rectangle("line", zone.x, zone.y, zone.w, zone.h, 8, 8)
love.graphics.setColor(0.5, 0.5, 0.6)
love.graphics.print(zone.label, zone.x + 5, zone.y - 16)
if zone.layout == "stack" then
if #zone.cards > 0 then
local top = zone.cards[#zone.cards]
if top ~= skip_card then
engine.drawCard(top, false)
end
love.graphics.setColor(1, 1, 1, 0.7)
love.graphics.printf(tostring(#zone.cards), zone.x, zone.y + zone.h - 22, zone.w, "center")
end
else
for _, card in ipairs(zone.cards) do
if card ~= skip_card then
engine.drawCard(card, false)
end
end
end
end
return engine
Wire up main.lua to display the game
You have all the data and all the rendering functions. Now you need to connect them. The main.lua file will load the config, build the starter deck, spawn creatures, deal a hand, and render everything on screen using the engine.
- Open main.lua in VS Code.
- Select all the existing code and replace it with the following:
local engine = require("engine")
local config = require("game_config")
local state = {}
function love.load()
love.graphics.setBackgroundColor(0.08, 0.08, 0.12)
-- Create zones with position, size, layout type, and label
state.zones = {
deck = engine.newZone(30, 600, 100, 150, "stack", "Deck"),
hand = engine.newZone(150, 600, 700, 150, "fan", "Hand"),
discard = engine.newZone(870, 600, 100, 150, "stack", "Discard"),
tableau = engine.newZone(30, 410, 940, 150, "row", "Tableau"),
creatures = engine.newZone(30, 60, 940, 170, "row", "Creatures"),
}
-- Build the starter deck from config, face-down
for _, card_id in ipairs(config.starter_deck) do
local def = config.card_defs[card_id]
local card = engine.newCard(card_id, def)
card.face_up = false
engine.addCard(state.zones.deck, card)
end
engine.shuffle(state.zones.deck)
-- Spawn creatures from config into the creatures zone
for _, cdef in ipairs(config.creature_defs) do
local creature = engine.newCreature(cdef)
engine.addCard(state.zones.creatures, creature)
end
drawHand()
end
-- Deal a hand of cards from the deck
function drawHand()
engine.dealCards(state.zones.deck, state.zones.hand, config.hand_size)
end
function love.update(dt)
end
function love.draw()
-- Title bar
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(config.title, 30, 10)
-- Render all zones
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], nil)
end
end
function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
end
function love.keypressed(key)
end
What does this code do?
- require("engine") and require("game_config") load the engine module and config table you just created. LOVE2D automatically looks for .lua files in the project folder.
- love.load() creates five zones (deck, hand, discard, tableau, creatures), builds the starter deck by looking up each card ID in the config, shuffles the deck, spawns all six dinosaurs, and deals 5 cards to the hand.
- love.draw() iterates through each zone name in render order and calls engine.drawZone to display them. Creatures render first at the top, then tableau, deck, discard, and hand at the bottom.
- Save main.lua.
- Run your game by opening a terminal in your board-game-engine folder and running this command:
love .
You should see a dark blue-black window with "Dino Hunt" in the top left. Six dinosaur cards sit in the Creatures zone across the top. Five face-up cards from your starter deck appear in the Hand zone at the bottom. The Deck zone on the left shows a face-down card with a count of 3.
Seeing an error or a black screen?
- If you see module 'engine' not found or module 'game_config' not found, make sure both files are saved directly inside the board-game-engine folder, at the same level as main.lua.
- If you see a Lua syntax error with a line number, check that line for a missing comma, bracket, or quotation mark. Lua table definitions require commas between entries.
- If the window opens but shows only the background color, check that game_config.lua ends with return config and engine.lua ends with return engine. Without the return statement, require() gets nil instead of the module table.
Help me debug my LOVE2D card game. I'm getting an error when running love . in my board-game-engine folder.
Awesome, I've got everything!
Make sure all three files are saved.
I'd like to double check the full code
local engine = require("engine")
local config = require("game_config")
local state = {}
function love.load()
love.graphics.setBackgroundColor(0.08, 0.08, 0.12)
state.zones = {
deck = engine.newZone(30, 600, 100, 150, "stack", "Deck"),
hand = engine.newZone(150, 600, 700, 150, "fan", "Hand"),
discard = engine.newZone(870, 600, 100, 150, "stack", "Discard"),
tableau = engine.newZone(30, 410, 940, 150, "row", "Tableau"),
creatures = engine.newZone(30, 60, 940, 170, "row", "Creatures"),
}
for _, card_id in ipairs(config.starter_deck) do
local def = config.card_defs[card_id]
local card = engine.newCard(card_id, def)
card.face_up = false
engine.addCard(state.zones.deck, card)
end
engine.shuffle(state.zones.deck)
for _, cdef in ipairs(config.creature_defs) do
local creature = engine.newCreature(cdef)
engine.addCard(state.zones.creatures, creature)
end
drawHand()
end
function drawHand()
engine.dealCards(state.zones.deck, state.zones.hand, config.hand_size)
end
function love.update(dt)
end
function love.draw()
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(config.title, 30, 10)
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], nil)
end
end
function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
end
function love.keypressed(key)
end
Your card data system and zone renderer are working. You can see cards on screen, but you cannot interact with them yet. Next up, you will add drag-and-drop so you can pick up cards from your hand and play them onto the tableau.
Drag-and-Drop Card Interaction
Your game board is set up with zones, a shuffled deck, and a hand of five cards. The problem is that nothing happens when you click on them.
In this step, you will wire up LOVE2D's mouse callbacks to let the player pick up cards from their hand, drag them across the screen, and drop them onto the tableau to play them.
In this step, get ready to:
- Set up drag state tracking for mouse input.
- Implement mouse callbacks to pick up, drag, and release cards.
- Handle card drops with zone snapping and a card-play function.
Set up drag state in love.load
The engine already has the two utility functions needed for hit testing: engine.pointInRect checks whether a point is inside a rectangle, and engine.findCardInZone iterates a zone's cards in reverse order to find the topmost card under the cursor.
Why reverse order?
Cards are drawn from index 1 to the end of the array. That means the last card in the array renders on top of all the others. Iterating in reverse ensures the visually topmost card is the one the player picks up when cards overlap.
Before you can drag anything, the game needs state variables to track what is being dragged and where it came from.
- In main.lua, find drawHand() at the bottom of love.load.
- Add the following state variables directly above the drawHand() call:
-- Track accumulated card strength this turn
state.strength = 0
-- Status message shown to the player
state.message = "Turn 1 - Drag cards to Tableau"
-- Drag state: which card, where it came from, mouse offset
state.drag = { card = nil, origin = nil, ox = 0, oy = 0 }
-- Current mouse position, updated every frame
state.mx, state.my = 0, 0
What does this code do?
- state.strength accumulates the strength values of cards played this turn.
- state.message holds a status string that displays on screen after each action.
- state.drag stores the card being dragged, the zone it came from, and the mouse offset so the card doesn't snap to the cursor's top-left corner.
- state.mx and state.my cache the mouse position so every function can read it without calling love.mouse.getPosition() repeatedly.
- Save main.lua.
- You won't see a change yet. The next chunk connects these state variables to mouse input.
Implement drag state and mouse callbacks
LOVE2D's drag-and-drop pattern uses three callbacks working together. love.mousepressed detects the click and grabs the card. love.update moves the card every frame to follow the cursor. love.draw renders the dragged card on top of everything else.
- In main.lua, replace the empty love.update function with the following:
function love.update(dt)
-- Read mouse position every frame
state.mx, state.my = love.mouse.getPosition()
-- If dragging a card, move it to follow the cursor
if state.drag.card then
state.drag.card.x = state.mx - state.drag.ox
state.drag.card.y = state.my - state.drag.oy
end
end
What does this code do?
Every frame, this reads the mouse position with love.mouse.getPosition(). If a card is being dragged, the card's position updates to follow the cursor minus the stored offset. The offset keeps the card anchored at the point where the player originally clicked it.
- Save main.lua.
- You won't see a change yet. The next chunk adds the click handler that starts the drag.
- Replace the empty love.mousepressed function with the following:
function love.mousepressed(x, y, button)
if button ~= 1 then return end
-- Check if the click is on a card in the hand zone
local card = engine.findCardInZone(state.zones.hand, x, y)
if card then
-- Store the card and its origin for potential return
state.drag.card = card
state.drag.origin = state.zones.hand
-- Record the offset between click point and card corner
state.drag.ox = x - card.x
state.drag.oy = y - card.y
-- Remove from hand so it doesn't render in the zone
engine.removeCard(state.zones.hand, card)
return
end
end
What does this code do?
- button ~= 1 filters out right-clicks and middle-clicks. Only the primary mouse button starts a drag.
- engine.findCardInZone checks if the click lands on a hand card. If it does, the card is stored in state.drag along with the offset and origin zone.
- engine.removeCard pulls the card out of the hand zone immediately. This prevents it from rendering in the hand while it is being dragged.
- Save main.lua.
- You won't see dragging yet. The draw function still needs to render the dragged card on top.
- Replace the entire love.draw function with the following updated version:
function love.draw()
-- Title bar
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(config.title, 30, 10)
-- Get the dragged card so zones can skip rendering it
local dragged = state.drag.card
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], dragged)
end
-- Status message below the zones
love.graphics.setColor(1, 1, 0.7)
love.graphics.print(state.message, 30, 330)
-- Render dragged card last so it appears on top of everything
if dragged then
engine.drawCard(dragged, true)
end
end
What's happening here?
- The dragged variable is passed into engine.drawZone as the skip_card parameter. This tells each zone to skip rendering that card, preventing a ghost card from lingering in the hand zone.
- Passing true to engine.drawCard(dragged, true) triggers the shadow effect. A dark translucent rectangle renders behind the card to create a lifted look.
- The dragged card renders after everything else, so it always appears on top of all zones and other cards.
- Save main.lua.
- Run your game by running:
love .
- Click on a card in the hand zone and drag it around the screen. The card should follow your cursor with a shadow underneath it.
- The hand zone re-layouts and the remaining cards spread out to fill the space.
You can pick up cards, but releasing the mouse button does nothing yet. The card vanishes because there is no drop handler.
Card not following the cursor?
- Make sure you saved main.lua after updating all three functions.
- Check that love.update references state.drag.card, not state.card. The drag state is nested inside state.drag.
- Verify you passed dragged (not nil) as the second argument to engine.drawZone. Without this, the card renders in the zone and at the cursor at the same time.
Why isn't my card dragging when I click on it in LOVE2D?
Handle card drops with zone snapping
Dragging a card is only half the interaction. The game needs to detect where the player drops the card and decide what happens next.
- In main.lua, replace the empty love.mousereleased function with the following:
function love.mousereleased(x, y, button)
if button ~= 1 or not state.drag.card then return end
local card = state.drag.card
local dropped = false
-- Check if the drop is over the tableau zone
local tz = state.zones.tableau
if engine.pointInRect(x, y, tz.x, tz.y, tz.w, tz.h) then
engine.addCard(tz, card)
playCard(card)
dropped = true
end
-- If not dropped on a valid zone, return card to origin
if not dropped then
engine.addCard(state.drag.origin, card)
end
-- Clear drag state
state.drag.card = nil
state.drag.origin = nil
end
What does this code do?
- engine.pointInRect checks if the mouse release point is inside the tableau zone's bounding box. If yes, the card snaps into the tableau and playCard(card) fires to apply the card's effects.
- If the player drops the card anywhere else, the card returns to its origin zone. This prevents cards from disappearing into the void.
- The drag state is cleared at the end regardless of where the card lands.
- Save main.lua. The drop handler is ready, but it calls playCard which does not exist yet. The next chunk adds it.
- Add the playCard function above love.update by pasting the following code:
function playCard(card)
-- Resource gains are stubs for now (resource system comes next step)
if card.food_gain > 0 then end
if card.meeple_gain > 0 then end
if card.power_gain > 0 then end
-- Accumulate this card's strength into the turn total
state.strength = state.strength + card.strength
-- Update the status message
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
What does this code do?
The resource gain lines (food_gain, meeple_gain, power_gain) are placeholder stubs. The full resource system is built in the next step. For now, playCard adds the card's strength to the running total and updates the status message so you can confirm cards are being played.
- Finally, replace the empty love.keypressed function with a quit handler:
function love.keypressed(key)
if key == "escape" then love.event.quit() end
end
- Save main.lua and run the game:
love .
- Drag a card from your hand and drop it on the Tableau zone. The card should snap into the tableau and the status message at the bottom should update to show something like Played Hunter | Strength: 2.
- Drag another card and drop it outside the Tableau. The card should snap back to your hand.
- Press Escape to quit the game.
Card vanishes on drop?
- Check that love.mousereleased has the if not dropped then fallback. Without it, any drop outside the Tableau loses the card permanently.
- Make sure state.drag.origin is set in love.mousepressed. If it is nil, the fallback engine.addCard(state.drag.origin, card) will error.
- If the message does not update, confirm that playCard is defined above love.mousereleased in the file. Lua reads top-to-bottom, so a function must be declared before it is called.
Help me debug why my card disappears when I drop it in LOVE2D.
Awesome, I've got everything!
Double check you have saved main.lua. No changes were needed in engine.lua or game_config.lua for this step.
I'd like to double check the full code
Here is the full main.lua as it should look at the end of this step.
local engine = require("engine")
local config = require("game_config")
local state = {}
function love.load()
love.graphics.setBackgroundColor(0.08, 0.08, 0.12)
state.zones = {
deck = engine.newZone(30, 600, 100, 150, "stack", "Deck"),
hand = engine.newZone(150, 600, 700, 150, "fan", "Hand"),
discard = engine.newZone(870, 600, 100, 150, "stack", "Discard"),
tableau = engine.newZone(30, 410, 940, 150, "row", "Tableau"),
creatures = engine.newZone(30, 60, 940, 170, "row", "Creatures"),
}
for _, card_id in ipairs(config.starter_deck) do
local def = config.card_defs[card_id]
local card = engine.newCard(card_id, def)
card.face_up = false
engine.addCard(state.zones.deck, card)
end
engine.shuffle(state.zones.deck)
for _, cdef in ipairs(config.creature_defs) do
local creature = engine.newCreature(cdef)
engine.addCard(state.zones.creatures, creature)
end
state.strength = 0
state.message = "Turn 1 - Drag cards to Tableau"
state.drag = { card = nil, origin = nil, ox = 0, oy = 0 }
state.mx, state.my = 0, 0
drawHand()
end
function drawHand()
engine.dealCards(state.zones.deck, state.zones.hand, config.hand_size)
end
function playCard(card)
if card.food_gain > 0 then end
if card.meeple_gain > 0 then end
if card.power_gain > 0 then end
state.strength = state.strength + card.strength
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
function love.update(dt)
state.mx, state.my = love.mouse.getPosition()
if state.drag.card then
state.drag.card.x = state.mx - state.drag.ox
state.drag.card.y = state.my - state.drag.oy
end
end
function love.draw()
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(config.title, 30, 10)
local dragged = state.drag.card
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], dragged)
end
love.graphics.setColor(1, 1, 0.7)
love.graphics.print(state.message, 30, 330)
if dragged then
engine.drawCard(dragged, true)
end
end
function love.mousepressed(x, y, button)
if button ~= 1 then return end
local card = engine.findCardInZone(state.zones.hand, x, y)
if card then
state.drag.card = card
state.drag.origin = state.zones.hand
state.drag.ox = x - card.x
state.drag.oy = y - card.y
engine.removeCard(state.zones.hand, card)
return
end
end
function love.mousereleased(x, y, button)
if button ~= 1 or not state.drag.card then return end
local card = state.drag.card
local dropped = false
local tz = state.zones.tableau
if engine.pointInRect(x, y, tz.x, tz.y, tz.w, tz.h) then
engine.addCard(tz, card)
playCard(card)
dropped = true
end
if not dropped then
engine.addCard(state.drag.origin, card)
end
state.drag.card = nil
state.drag.origin = nil
end
function love.keypressed(key)
if key == "escape" then love.event.quit() end
end
You have a working drag-and-drop system. Cards lift from the hand, follow the cursor, and snap into the tableau on drop. Next up, you will build the resource tracking system and a full turn cycle so the game actually progresses.
Resources and Turn Management
Your cards hit the tableau and accumulate strength, but the game has no economy. Resources don't track, turns don't cycle, and your deck runs dry after a single hand.
In this step, you'll wire up a resource system for food, meeples, and power. You'll build a turn cycle that discards, reshuffles, and deals fresh hands. You'll also activate persistent tableau cards that generate value every turn.
In this step, get ready to:
- Track and display food, meeples, and power as capped resources.
- Build an End Turn button that discards, reshuffles, and deals a new hand.
- Activate persistent tableau cards at the start of each turn.
Add resource tracking and display
The game config already defines three resources (food, meeples, power) with max values and starting amounts. You need engine code to render them and game code to track them.
- In engine.lua, find return engine at the bottom of the file.
- Add the following engine.drawResources function above that line:
function engine.drawResources(resources, x, y)
for i, res in ipairs(resources) do
local rx = x + (i - 1) * 150
-- Dark background for each resource counter
love.graphics.setColor(0.1, 0.1, 0.15, 0.85)
love.graphics.rectangle("fill", rx, y, 135, 36, 6, 6)
-- Resource icon and name in the resource's color
love.graphics.setColor(res.color[1], res.color[2], res.color[3])
love.graphics.print(res.icon .. " " .. res.name, rx + 8, y + 2)
-- Current / max value in white
love.graphics.setColor(1, 1, 1)
love.graphics.print(res.current .. " / " .. res.max, rx + 8, y + 18)
end
end
What does this code do?
- It loops through each resource and draws a small counter panel spaced 150 pixels apart.
- Each counter shows the icon and name in the resource's defined color, plus a white current/max readout below.
You won't see the counters yet. You'll call this function from main.lua after wiring up the resource state.
Now you need game state to feed into that renderer. Each resource needs a current value that changes during play and a max cap.
- In main.lua, find love.load(). Add the resource initialization block right after the setBackgroundColor line and before state.zones:
-- Build resource state from config definitions
state.resources = {}
for _, def in ipairs(config.resources) do
state.resources[def.key] = {
name = def.name, key = def.key, icon = def.icon,
color = def.color, current = def.start, max = def.max,
}
end
What does this code do?
It reads each resource definition from config.resources and creates a runtime table keyed by the resource's key (food, meeples, power). Each entry tracks the current and max values so the game can modify them during play.
- Still inside love.load(), find the lines that set state.strength and state.message. Replace that block with the following expanded state initialization:
state.turn = 1
state.phase = "action"
state.strength = 0
state.score = 0
state.message = "Turn 1 - Drag cards to Tableau, click creatures to attack"
state.game_over = false
What are these new state fields?
- state.turn counts which turn the player is on.
- state.phase tracks the current game phase. For now it stays on "action" but gives you a hook for future phases.
- state.score and state.game_over prepare the game for scoring and end detection in the next step.
Next, you need helper functions to safely modify resources during play. These clamp values to the max and check for sufficient resources before spending.
- Add the addResource and spendResource functions below the drawHand() function in main.lua:
function addResource(key, amount)
local res = state.resources[key]
if res then
-- Clamp to max so resources never exceed the cap
res.current = math.min(res.current + amount, res.max)
end
end
function spendResource(key, amount)
local res = state.resources[key]
-- Only spend if the player has enough
if res and res.current >= amount then
res.current = res.current - amount
return true
end
return false
end
What do these helpers do?
- addResource increases a resource by amount but clamps it to the max (6). Gaining 3 food when you already have 5 gives you 6, not 8.
- spendResource returns true if the player can afford the cost and deducts it. It returns false if they can't, leaving the balance untouched.
The existing playCard function has placeholder if blocks that don't actually add resources yet. Time to wire them up.
- In main.lua, find the playCard function. It currently looks like this:
function playCard(card)
if card.food_gain > 0 then end
if card.meeple_gain > 0 then end
if card.power_gain > 0 then end
state.strength = state.strength + card.strength
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
- Replace the entire playCard function with this version that calls addResource for each gain type:
function playCard(card)
-- Apply resource gains from the card
if card.food_gain > 0 then addResource("food", card.food_gain) end
if card.meeple_gain > 0 then addResource("meeples", card.meeple_gain) end
if card.power_gain > 0 then addResource("power", card.power_gain) end
state.strength = state.strength + card.strength
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
Now you need to render all this new state on screen. The title bar should show the turn number, score, and attack power. The resource counters should appear between the creatures and tableau zones.
- In main.lua, find the love.draw() function. Replace its entire contents with this updated version:
function love.draw()
-- Title bar with game stats
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(
config.title .. " | Turn: " .. state.turn ..
" | Score: " .. state.score ..
" | Strength: " .. state.strength .. " + " .. state.resources.power.current .. "P",
30, 10
)
-- Build resource list for the renderer
local res_list = {}
for _, def in ipairs(config.resources) do
table.insert(res_list, state.resources[def.key])
end
engine.drawResources(res_list, 30, 280)
-- Draw all zones, skipping the dragged card
local dragged = state.drag.card
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], dragged)
end
-- Status message
love.graphics.setColor(1, 1, 0.7)
love.graphics.print(state.message, 30, 330)
-- Dragged card renders on top of everything
if dragged then
engine.drawCard(dragged, true)
end
end
What changed in love.draw?
- The title bar now concatenates the turn number, score, and a strength + power readout so the player always knows their attack total.
- A res_list array is built from config order (food, meeples, power) so the counters always appear in the same sequence.
- engine.drawResources renders the three counters at y=280, right between the creatures zone and the tableau.
- Save both engine.lua and main.lua.
- Run your game by running:
love .
- You should see three resource counters (Food, Meeples, Power) between the creatures and tableau zones. The title bar now shows Turn, Score, and Strength values.
- Drag a Hunter card to the tableau. The Food counter should increase from 2 to 3, and the status message should update.
Resources not changing when you play a card?
- Make sure you replaced the entire playCard function. The old version had empty if ... then end blocks that did nothing.
- Check that addResource and spendResource are defined above playCard in the file, or at least before they get called.
- Verify that state.resources initialization appears before state.zones in love.load.
- Still stuck? Why aren't my resource counters updating when I play a card in LOVE2D?
Implement the End Turn button and turn cycle
Playing cards is satisfying, but there's no way to end a turn, draw a fresh hand, or cycle through the deck. The turn cycle is the heartbeat of any card game.
- In engine.lua, add the engine.drawButton function above return engine (and below engine.drawResources):
function engine.drawButton(text, x, y, w, h, hover)
-- Highlight on hover, darker when idle
if hover then
love.graphics.setColor(0.3, 0.55, 0.85)
else
love.graphics.setColor(0.2, 0.35, 0.55)
end
love.graphics.rectangle("fill", x, y, w, h, 6, 6)
love.graphics.setColor(1, 1, 1)
love.graphics.printf(text, x, y + h / 2 - 8, w, "center")
end
What does this code do?
The button changes color when the mouse hovers over it using the hover parameter. The calling code passes true or false based on a hit test against the mouse position.
- Save engine.lua.
Awesome, I've got everything!
Great. Double check you've saved engine.lua before moving on.
I'd like to double check the full code
local engine = {}
engine.CARD_W = 90
engine.CARD_H = 130
function engine.newCard(id, def)
return {
id = id,
name = def.name or id,
type = def.type or "tribe",
strength = def.strength or 0,
cost = def.cost or 0,
cost_type = def.cost_type or "",
food_gain = def.food_gain or 0,
meeple_gain = def.meeple_gain or 0,
power_gain = def.power_gain or 0,
persistent = def.persistent or false,
description = def.description or "",
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
face_up = true,
}
end
function engine.newCreature(def)
return {
id = def.id,
name = def.name,
type = "creature",
hp = def.hp,
points = def.points,
reward_food = def.reward_food or 0,
color = def.color or {0.5, 0.5, 0.5},
x = 0, y = 0,
defeated = false,
}
end
function engine.newZone(x, y, w, h, layout, label)
return {
x = x, y = y, w = w, h = h,
layout = layout or "row",
label = label or "",
cards = {},
}
end
function engine.addCard(zone, card)
table.insert(zone.cards, card)
engine.layoutZone(zone)
end
function engine.removeCard(zone, card)
for i, c in ipairs(zone.cards) do
if c == card then
table.remove(zone.cards, i)
engine.layoutZone(zone)
return true
end
end
return false
end
function engine.layoutZone(zone)
local count = #zone.cards
if count == 0 then return end
local W, H = engine.CARD_W, engine.CARD_H
if zone.layout == "stack" then
for _, card in ipairs(zone.cards) do
card.x = zone.x + (zone.w - W) / 2
card.y = zone.y + (zone.h - H) / 2
end
else
local max_spacing = W + 10
local spacing = math.min(max_spacing, (zone.w - W) / math.max(count - 1, 1))
local total = (count - 1) * spacing + W
local sx = zone.x + (zone.w - total) / 2
for i, card in ipairs(zone.cards) do
card.x = sx + (i - 1) * spacing
card.y = zone.y + (zone.h - H) / 2
end
end
end
function engine.shuffle(zone)
local cards = zone.cards
for i = #cards, 2, -1 do
local j = love.math.random(1, i)
cards[i], cards[j] = cards[j], cards[i]
end
engine.layoutZone(zone)
end
function engine.dealCards(from, to, count)
local dealt = 0
for i = 1, count do
if #from.cards == 0 then break end
local card = table.remove(from.cards, #from.cards)
card.face_up = true
table.insert(to.cards, card)
dealt = dealt + 1
end
engine.layoutZone(from)
engine.layoutZone(to)
return dealt
end
function engine.moveAllCards(from, to)
for _, card in ipairs(from.cards) do
table.insert(to.cards, card)
end
from.cards = {}
engine.layoutZone(from)
engine.layoutZone(to)
end
function engine.pointInRect(px, py, rx, ry, rw, rh)
return px >= rx and px <= rx + rw and py >= ry and py <= ry + rh
end
function engine.findCardInZone(zone, px, py)
local W, H = engine.CARD_W, engine.CARD_H
for i = #zone.cards, 1, -1 do
local c = zone.cards[i]
if engine.pointInRect(px, py, c.x, c.y, W, H) then
return c, i
end
end
return nil, nil
end
function engine.drawCard(card, dragging)
local W, H = engine.CARD_W, engine.CARD_H
local cx, cy = card.x, card.y
if dragging then
love.graphics.setColor(0, 0, 0, 0.3)
love.graphics.rectangle("fill", cx + 4, cy + 4, W, H, 6, 6)
end
love.graphics.setColor(card.color[1], card.color[2], card.color[3])
love.graphics.rectangle("fill", cx, cy, W, H, 6, 6)
love.graphics.setColor(0.2, 0.2, 0.2)
love.graphics.rectangle("line", cx, cy, W, H, 6, 6)
if card.face_up ~= false then
if card.type == "creature" then
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(1, 0.9, 0.2)
love.graphics.printf("HP: " .. card.hp, cx + 4, cy + 40, W - 8, "center")
love.graphics.setColor(0.6, 1, 0.6)
love.graphics.printf(card.points .. " pts", cx + 4, cy + 60, W - 8, "center")
love.graphics.setColor(0.8, 0.8, 0.9)
love.graphics.printf("+" .. card.reward_food .. " Food", cx + 4, cy + 85, W - 8, "center")
if card.defeated then
love.graphics.setColor(1, 0, 0, 0.6)
love.graphics.setLineWidth(3)
love.graphics.line(cx + 10, cy + 10, cx + W - 10, cy + H - 10)
love.graphics.line(cx + W - 10, cy + 10, cx + 10, cy + H - 10)
love.graphics.setLineWidth(1)
end
else
love.graphics.setColor(1, 1, 1)
love.graphics.printf(card.name, cx + 4, cy + 8, W - 8, "center")
love.graphics.setColor(0.85, 0.85, 0.9, 0.7)
love.graphics.printf(card.type, cx + 4, cy + 26, W - 8, "center")
if card.strength > 0 then
love.graphics.setColor(1, 0.85, 0.2)
love.graphics.printf("Str " .. card.strength, cx + 4, cy + 50, W - 8, "center")
end
love.graphics.setColor(0.9, 0.9, 0.9)
love.graphics.printf(card.description, cx + 4, cy + H - 42, W - 8, "center")
if card.cost > 0 and card.cost_type ~= "" then
love.graphics.setColor(1, 0.8, 0.2)
local ct = card.cost_type:sub(1, 1):upper()
love.graphics.printf(card.cost .. ct, cx + 4, cy + H - 18, W - 8, "right")
end
end
else
love.graphics.setColor(0.25, 0.25, 0.4)
love.graphics.rectangle("fill", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
love.graphics.setColor(0.35, 0.35, 0.55)
love.graphics.rectangle("line", cx + 8, cy + 8, W - 16, H - 16, 4, 4)
end
end
function engine.drawZone(zone, skip_card)
love.graphics.setColor(0.12, 0.12, 0.18, 0.6)
love.graphics.rectangle("fill", zone.x, zone.y, zone.w, zone.h, 8, 8)
love.graphics.setColor(0.35, 0.35, 0.45, 0.8)
love.graphics.rectangle("line", zone.x, zone.y, zone.w, zone.h, 8, 8)
love.graphics.setColor(0.5, 0.5, 0.6)
love.graphics.print(zone.label, zone.x + 5, zone.y - 16)
if zone.layout == "stack" then
if #zone.cards > 0 then
local top = zone.cards[#zone.cards]
if top ~= skip_card then
engine.drawCard(top, false)
end
love.graphics.setColor(1, 1, 1, 0.7)
love.graphics.printf(tostring(#zone.cards), zone.x, zone.y + zone.h - 22, zone.w, "center")
end
else
for _, card in ipairs(zone.cards) do
if card ~= skip_card then
engine.drawCard(card, false)
end
end
end
end
function engine.drawResources(resources, x, y)
for i, res in ipairs(resources) do
local rx = x + (i - 1) * 150
love.graphics.setColor(0.1, 0.1, 0.15, 0.85)
love.graphics.rectangle("fill", rx, y, 135, 36, 6, 6)
love.graphics.setColor(res.color[1], res.color[2], res.color[3])
love.graphics.print(res.icon .. " " .. res.name, rx + 8, y + 2)
love.graphics.setColor(1, 1, 1)
love.graphics.print(res.current .. " / " .. res.max, rx + 8, y + 18)
end
end
function engine.drawButton(text, x, y, w, h, hover)
if hover then
love.graphics.setColor(0.3, 0.55, 0.85)
else
love.graphics.setColor(0.2, 0.35, 0.55)
end
love.graphics.rectangle("fill", x, y, w, h, 6, 6)
love.graphics.setColor(1, 1, 1)
love.graphics.printf(text, x, y + h / 2 - 8, w, "center")
end
return engine
Now back in main.lua, you need the turn cycle logic. When the player ends their turn, all non-persistent cards leave the tableau, strength resets, and a new hand is dealt.
- First, update drawHand() to handle deck reshuffling. The starter deck has only 8 cards, but you draw 5 per turn. Without reshuffling, the deck runs out after two draws.
- Replace the existing drawHand() function with this version:
function drawHand()
local deck = state.zones.deck
local hand = state.zones.hand
local discard = state.zones.discard
-- If not enough cards in deck, reshuffle discard pile back in
if #deck.cards < config.hand_size then
engine.moveAllCards(discard, deck)
for _, c in ipairs(deck.cards) do
c.face_up = false
end
engine.shuffle(deck)
end
engine.dealCards(deck, hand, config.hand_size)
end
What does the reshuffle logic do?
Before dealing, it checks if the deck has fewer cards than hand_size (5). If so, it moves all discarded cards back into the deck, flips them face-down, and shuffles. This creates the classic deck-builder cycle: play cards, discard them, eventually reshuffle and see them again.
- Add the endTurn() function below playCard() in main.lua:
function endTurn()
if state.game_over then return end
-- Move all hand cards to discard
engine.moveAllCards(state.zones.hand, state.zones.discard)
-- Keep persistent tableau cards, discard the rest
local keep = {}
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
table.insert(keep, card)
else
table.insert(state.zones.discard.cards, card)
end
end
state.zones.tableau.cards = keep
engine.layoutZone(state.zones.tableau)
engine.layoutZone(state.zones.discard)
-- Reset combat state and advance turn
state.strength = 0
state.turn = state.turn + 1
-- Check if all creatures are defeated
local all_defeated = true
for _, c in ipairs(state.zones.creatures.cards) do
if not c.defeated then all_defeated = false end
end
if all_defeated then
state.game_over = true
state.message = "All creatures defeated! Final score: " .. state.score
return
end
drawHand()
state.message = "Turn " .. state.turn .. " - Drag cards to Tableau, click creatures to attack"
end
What does endTurn do?
- It moves all remaining hand cards and non-persistent tableau cards into the discard pile.
- Cards marked persistent = true (like Totems) survive between turns. This is the engine-building mechanic.
- It checks if all creatures have been defeated. If so, the game ends with a final score message.
- Otherwise it calls drawHand() to deal a fresh hand for the next turn.
The turn logic exists, but nothing triggers it yet. You need a button on screen and a click handler to fire endTurn().
- In love.draw(), find the status message line (love.graphics.print(state.message, 30, 330)). Add this End Turn button rendering right above it:
-- End Turn button with hover detection
local btn_x, btn_y, btn_w, btn_h = 1020, 620, 130, 50
local btn_hover = engine.pointInRect(state.mx, state.my, btn_x, btn_y, btn_w, btn_h)
engine.drawButton("End Turn", btn_x, btn_y, btn_w, btn_h, btn_hover)
What does this code do?
It places the button at x=1020, y=620 (to the right of the hand zone). The hover detection reuses engine.pointInRect to check if the mouse is inside the button rectangle every frame.
- Now update love.mousepressed to handle the button click. Find the existing function and replace it with this version:
function love.mousepressed(x, y, button)
if button ~= 1 or state.game_over then return end
-- Check End Turn button click
if engine.pointInRect(x, y, 1020, 620, 130, 50) then
endTurn()
return
end
-- Check for hand card drag
local card = engine.findCardInZone(state.zones.hand, x, y)
if card then
state.drag.card = card
state.drag.origin = state.zones.hand
state.drag.ox = x - card.x
state.drag.oy = y - card.y
engine.removeCard(state.zones.hand, card)
return
end
end
What changed in mousepressed?
- The guard now also checks state.game_over to ignore clicks when the game has ended.
- The End Turn hit test runs before the hand card check so the button takes priority.
- Save main.lua and run the game by running:
love .
- You should see an **End Turn** button to the right of your hand zone. It highlights when you hover over it.
- Play a few cards to the tableau, then click End Turn. The turn counter should increment, your hand should refill, and the played cards should move to the discard pile.
- Play through 2-3 turns. When the deck runs out, the discard pile should automatically reshuffle back in.
End Turn button not working?
- If the button doesn't appear, make sure you added the rendering code inside love.draw() and saved engine.lua with the drawButton function.
- If clicking does nothing, verify the coordinates in love.mousepressed match the button position: 1020, 620, 130, 50.
- If your hand doesn't refill, check that endTurn() calls drawHand() at the end.
- Still stuck? My End Turn button isn't triggering the turn cycle in LOVE2D. Help me debug.
Activate persistent tableau cards
Some cards, like the Totem, have persistent = true in their definition. They survive between turns and should generate resources at the start of each new turn. This is the engine-building mechanic that rewards players for investing in persistent cards early.
- In main.lua, find the drawHand() function. Locate the last line:
engine.dealCards(deck, hand, config.hand_size)
- Add the following persistent card activation block below that line, still inside drawHand():
-- Reset strength and activate persistent tableau cards
state.strength = 0
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
addResource("power", card.power_gain or 0)
end
end
Why activate persistent cards here?
drawHand() runs at the start of every turn. By placing activation here, persistent cards like the Totem automatically generate their power bonus each turn without any extra logic in endTurn(). The more Totems on your tableau, the more power you get each turn.
- Save main.lua and run the game by running:
love .
- Play through a full turn cycle: drag cards to the tableau, watch resources change, then click End Turn.
- The title bar should show the updated turn number. Your hand should refill with 5 new cards.
- The status message should update to show the current turn number.
Game crashing on End Turn?
- If you see a nil error on state.resources.power.current, make sure the resource initialization loop in love.load runs before anything accesses state.resources.
- If persistent cards aren't activating, check that the activation code is inside drawHand() after the engine.dealCards call, not outside the function.
- Still stuck? My LOVE2D card game crashes or behaves oddly on End Turn. Help me debug the turn cycle.
Awesome, I've got everything!
Great. Double check you've saved main.lua before moving on.
I'd like to double check the full code
local engine = require("engine")
local config = require("game_config")
local state = {}
function love.load()
love.graphics.setBackgroundColor(0.08, 0.08, 0.12)
state.resources = {}
for _, def in ipairs(config.resources) do
state.resources[def.key] = {
name = def.name, key = def.key, icon = def.icon,
color = def.color, current = def.start, max = def.max,
}
end
state.zones = {
deck = engine.newZone(30, 600, 100, 150, "stack", "Deck"),
hand = engine.newZone(150, 600, 700, 150, "fan", "Hand"),
discard = engine.newZone(870, 600, 100, 150, "stack", "Discard"),
tableau = engine.newZone(30, 410, 940, 150, "row", "Tableau"),
creatures = engine.newZone(30, 60, 940, 170, "row", "Creatures"),
}
for _, card_id in ipairs(config.starter_deck) do
local def = config.card_defs[card_id]
local card = engine.newCard(card_id, def)
card.face_up = false
engine.addCard(state.zones.deck, card)
end
engine.shuffle(state.zones.deck)
for _, cdef in ipairs(config.creature_defs) do
local creature = engine.newCreature(cdef)
engine.addCard(state.zones.creatures, creature)
end
state.turn = 1
state.phase = "action"
state.strength = 0
state.score = 0
state.message = "Turn 1 - Drag cards to Tableau, click creatures to attack"
state.game_over = false
state.drag = { card = nil, origin = nil, ox = 0, oy = 0 }
state.mx, state.my = 0, 0
drawHand()
end
function drawHand()
local deck = state.zones.deck
local hand = state.zones.hand
local discard = state.zones.discard
if #deck.cards < config.hand_size then
engine.moveAllCards(discard, deck)
for _, c in ipairs(deck.cards) do
c.face_up = false
end
engine.shuffle(deck)
end
engine.dealCards(deck, hand, config.hand_size)
state.strength = 0
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
addResource("power", card.power_gain or 0)
end
end
end
function addResource(key, amount)
local res = state.resources[key]
if res then
res.current = math.min(res.current + amount, res.max)
end
end
function spendResource(key, amount)
local res = state.resources[key]
if res and res.current >= amount then
res.current = res.current - amount
return true
end
return false
end
function playCard(card)
if card.food_gain > 0 then addResource("food", card.food_gain) end
if card.meeple_gain > 0 then addResource("meeples", card.meeple_gain) end
if card.power_gain > 0 then addResource("power", card.power_gain) end
state.strength = state.strength + card.strength
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
function endTurn()
if state.game_over then return end
engine.moveAllCards(state.zones.hand, state.zones.discard)
local keep = {}
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
table.insert(keep, card)
else
table.insert(state.zones.discard.cards, card)
end
end
state.zones.tableau.cards = keep
engine.layoutZone(state.zones.tableau)
engine.layoutZone(state.zones.discard)
state.strength = 0
state.turn = state.turn + 1
local all_defeated = true
for _, c in ipairs(state.zones.creatures.cards) do
if not c.defeated then all_defeated = false end
end
if all_defeated then
state.game_over = true
state.message = "All creatures defeated! Final score: " .. state.score
return
end
drawHand()
state.message = "Turn " .. state.turn .. " - Drag cards to Tableau, click creatures to attack"
end
function love.update(dt)
state.mx, state.my = love.mouse.getPosition()
if state.drag.card then
state.drag.card.x = state.mx - state.drag.ox
state.drag.card.y = state.my - state.drag.oy
end
end
function love.draw()
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(
config.title .. " | Turn: " .. state.turn ..
" | Score: " .. state.score ..
" | Strength: " .. state.strength .. " + " .. state.resources.power.current .. "P",
30, 10
)
local res_list = {}
for _, def in ipairs(config.resources) do
table.insert(res_list, state.resources[def.key])
end
engine.drawResources(res_list, 30, 280)
local dragged = state.drag.card
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], dragged)
end
local btn_x, btn_y, btn_w, btn_h = 1020, 620, 130, 50
local btn_hover = engine.pointInRect(state.mx, state.my, btn_x, btn_y, btn_w, btn_h)
engine.drawButton("End Turn", btn_x, btn_y, btn_w, btn_h, btn_hover)
love.graphics.setColor(1, 1, 0.7)
love.graphics.print(state.message, 30, 330)
if dragged then
engine.drawCard(dragged, true)
end
end
function love.mousepressed(x, y, button)
if button ~= 1 or state.game_over then return end
if engine.pointInRect(x, y, 1020, 620, 130, 50) then
endTurn()
return
end
local card = engine.findCardInZone(state.zones.hand, x, y)
if card then
state.drag.card = card
state.drag.origin = state.zones.hand
state.drag.ox = x - card.x
state.drag.oy = y - card.y
engine.removeCard(state.zones.hand, card)
return
end
end
function love.mousereleased(x, y, button)
if button ~= 1 or not state.drag.card then return end
local card = state.drag.card
local dropped = false
local tz = state.zones.tableau
if engine.pointInRect(x, y, tz.x, tz.y, tz.w, tz.h) then
engine.addCard(tz, card)
playCard(card)
dropped = true
end
if not dropped then
engine.addCard(state.drag.origin, card)
end
state.drag.card = nil
state.drag.origin = nil
end
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
end
Your game now has a working economy and a full turn cycle. Next up, you'll add creature combat so all that strength and power you've been accumulating actually does something.
Creature Combat and Win Condition
Your turn cycle is running smoothly. Cards flow from deck to hand, the tableau accumulates strength, and resources tick up each turn. But all that power has nowhere to go yet.
In this step, you will wire up the combat system so the player can spend their accumulated attack power to defeat dinosaurs. You will also add an attack indicator, a game over screen, and a restart key to complete the full game loop.
In this step, get ready to:
- Display an attack total panel so the player knows their current fighting power.
- Implement creature combat with click-to-attack resolution.
- Add a game over overlay and restart functionality.
Add the attack total panel
The player accumulates strength from tribe cards and power from totems and traps. Before they can make smart combat decisions, they need to see their total attack value at a glance.
- In main.lua, find the love.draw function and locate this line inside it:
engine.drawButton("End Turn", btn_x, btn_y, btn_w, btn_h, btn_hover)
- Add the following ATK panel code directly below that line:
-- Attack total indicator panel
local atk_x, atk_y, atk_w, atk_h = 1020, 420, 130, 50
love.graphics.setColor(0.15, 0.15, 0.2, 0.8)
love.graphics.rectangle("fill", atk_x, atk_y, atk_w, atk_h, 6, 6)
love.graphics.setColor(1, 0.85, 0.2)
local total_atk = state.strength + state.resources.power.current
love.graphics.printf("ATK: " .. total_atk, atk_x, atk_y + atk_h / 2 - 8, atk_w, "center")
What does this code do?
- The panel sits at (1020, 420), directly above the End Turn button on the right side of the screen.
- total_atk combines state.strength (from played tribe cards) with the current power resource. This is the same formula the combat system will use.
- The gold color matches the strength text on cards, giving the player a visual connection between played cards and their attack total.
- Save main.lua.
- Run love . in the terminal. You should see a gold "ATK: 0" panel on the right side of the screen, above the End Turn button.
- Drag a few cards to the tableau. The ATK number should update as you play strength cards.
ATK panel not showing?
- Make sure the ATK code is inside the love.draw function, not outside it.
- Check that the ATK block comes after the engine.drawButton line and before the message display line.
- Help me debug why my ATK panel is not appearing in LOVE2D.
Implement combat resolution
Each creature in the creatures zone has an HP value. When the player clicks a creature, the game compares their total attack against that HP. If the attack is high enough, the creature is defeated and the player earns points and food.
- In main.lua, find the playCard function. Add the following attackCreature function directly below the end of playCard:
function attackCreature(creature)
-- Calculate total attack from played cards + power resource
local total = state.strength + state.resources.power.current
if creature.defeated then
state.message = creature.name .. " is already defeated"
return
end
if total >= creature.hp then
-- Successful attack: award points and food, reset attack
creature.defeated = true
state.score = state.score + creature.points
addResource("food", creature.reward_food)
state.resources.power.current = 0
state.strength = 0
state.message = "Defeated " .. creature.name .. "! +" .. creature.points .. " pts"
else
-- Not enough attack power
state.message = "Need " .. creature.hp .. " ATK. You have " .. total
end
end
What does this code do?
- total adds state.strength (from cards played this turn) and the current power resource. This is the same formula the ATK panel displays.
- On a successful attack, the creature is marked defeated = true. The engine already renders a red X overlay on defeated creatures.
- Both strength and power reset to 0 after a successful attack. This forces the player to rebuild attack power each time, creating meaningful turn-by-turn decisions.
- A failed attack shows the player exactly how much more ATK they need.
The function exists, but nothing calls it yet. You need to detect clicks on creature cards and route them to attackCreature.
- In love.mousepressed, find the End Turn button check. It looks like this:
if engine.pointInRect(x, y, 1020, 620, 130, 50) then
endTurn()
return
end
- Add the following creature click check directly below that block, before the hand card detection:
-- Check if player clicked a creature to attack it
local creature = engine.findCardInZone(state.zones.creatures, x, y)
if creature and creature.type == "creature" then
attackCreature(creature)
return
end
Why check creature clicks before hand cards?
The order of checks inside love.mousepressed matters. Each check ends with return, so only the first matching action fires. Creature clicks are checked before hand card drags because the zones don't overlap. But putting important game actions earlier in the chain makes the priority clear.
- Save main.lua.
- Run love . in the terminal.
- Drag a few strength cards (Hunters, Warriors) to the tableau to build up your ATK total.
- Click on the Raptor creature card (4 HP). If your ATK total is 4 or higher, you should see a red X drawn over the Raptor and a victory message at the bottom.
- If your ATK is too low, the status message should show how much more you need.
Clicking creatures does nothing?
- Make sure the creature click block is inside love.mousepressed, between the End Turn check and the hand card check.
- Verify that the creature check references state.zones.creatures (not state.zones.creature without the "s").
- Check that the attackCreature function is defined before love.mousepressed in the file. Lua reads top to bottom, so the function must exist before it is called.
- Help me debug why clicking creatures is not working in my LOVE2D card game.
Add game over detection and restart
The endTurn function already checks if all creatures are defeated and sets state.game_over = true. But the player sees nothing special when they win. You need a full-screen overlay showing the final score.
- In love.draw, find this line near the bottom of the function:
if dragged then
- Add the game over overlay directly above that line:
-- Game over overlay: semi-transparent black with final results
if state.game_over then
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, 1280, 800)
love.graphics.setColor(1, 1, 0.5)
love.graphics.printf("GAME OVER", 0, 300, 1280, "center")
love.graphics.setColor(1, 1, 1)
love.graphics.printf("Final Score: " .. state.score .. " in " .. state.turn .. " turns", 0, 340, 1280, "center")
love.graphics.printf("Press R to restart", 0, 380, 1280, "center")
end
What does this code do?
- The overlay draws a semi-transparent black rectangle over the entire 1280x800 window. This dims the game board underneath.
- love.graphics.printf with a width of 1280 and "center" alignment centers the text across the full window width.
- The overlay renders before the dragged card, so it covers the entire board. The dragged card would render on top if someone were mid-drag, but love.mousepressed already blocks input when state.game_over is true.
The overlay tells the player to press R to restart, but that key does nothing yet.
- In love.keypressed, find this block:
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
end
- Add the restart handler below the escape check, so the full function looks like this:
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
-- Restart the game by re-running love.load
if key == "r" then
love.load()
end
end
Why does calling love.load() restart the game?
love.load() reinitializes every field in the state table: resources, zones, turn counter, score, and game_over flag. It rebuilds the deck from the config, shuffles it, respawns creatures, and deals a fresh hand. Because all game state lives in a single table that gets overwritten, calling love.load() again is a clean reset.
- Save main.lua.
- Run love . in the terminal.
- Play through the full game: drag cards to the tableau, build up your ATK, click creatures to defeat them, and press End Turn to draw a new hand.
- After defeating all six creatures and ending the turn, you should see a dark overlay with "GAME OVER", your final score, the number of turns, and "Press R to restart".
- Press R. The game should reset to Turn 1 with a fresh hand and all creatures alive again.
Game over screen not appearing?
- The game over check runs inside endTurn(), not immediately when you defeat a creature. You must click End Turn after defeating the last creature for the overlay to appear.
- Make sure the if state.game_over then block is inside love.draw, placed before the if dragged then block.
- If pressing R does nothing, check that the if key == "r" check uses a lowercase "r" in quotes.
- Help me debug my game over screen in LOVE2D.
Awesome, I've got everything!
Great. Double check you have saved main.lua.
I'd like to double check the full code
Here is the complete main.lua as it should look at the end of this step.
local engine = require("engine")
local config = require("game_config")
local state = {}
function love.load()
love.graphics.setBackgroundColor(0.08, 0.08, 0.12)
state.resources = {}
for _, def in ipairs(config.resources) do
state.resources[def.key] = {
name = def.name, key = def.key, icon = def.icon,
color = def.color, current = def.start, max = def.max,
}
end
state.zones = {
deck = engine.newZone(30, 600, 100, 150, "stack", "Deck"),
hand = engine.newZone(150, 600, 700, 150, "fan", "Hand"),
discard = engine.newZone(870, 600, 100, 150, "stack", "Discard"),
tableau = engine.newZone(30, 410, 940, 150, "row", "Tableau"),
creatures = engine.newZone(30, 60, 940, 170, "row", "Creatures"),
}
for _, card_id in ipairs(config.starter_deck) do
local def = config.card_defs[card_id]
local card = engine.newCard(card_id, def)
card.face_up = false
engine.addCard(state.zones.deck, card)
end
engine.shuffle(state.zones.deck)
for _, cdef in ipairs(config.creature_defs) do
local creature = engine.newCreature(cdef)
engine.addCard(state.zones.creatures, creature)
end
state.turn = 1
state.phase = "action"
state.strength = 0
state.score = 0
state.message = "Turn 1 - Drag cards to Tableau, click creatures to attack"
state.game_over = false
state.drag = { card = nil, origin = nil, ox = 0, oy = 0 }
state.mx, state.my = 0, 0
drawHand()
end
function drawHand()
local deck = state.zones.deck
local hand = state.zones.hand
local discard = state.zones.discard
if #deck.cards < config.hand_size then
engine.moveAllCards(discard, deck)
for _, c in ipairs(deck.cards) do
c.face_up = false
end
engine.shuffle(deck)
end
engine.dealCards(deck, hand, config.hand_size)
state.strength = 0
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
addResource("power", card.power_gain or 0)
end
end
end
function addResource(key, amount)
local res = state.resources[key]
if res then
res.current = math.min(res.current + amount, res.max)
end
end
function spendResource(key, amount)
local res = state.resources[key]
if res and res.current >= amount then
res.current = res.current - amount
return true
end
return false
end
function playCard(card)
if card.food_gain > 0 then addResource("food", card.food_gain) end
if card.meeple_gain > 0 then addResource("meeples", card.meeple_gain) end
if card.power_gain > 0 then addResource("power", card.power_gain) end
state.strength = state.strength + card.strength
state.message = "Played " .. card.name .. " | Strength: " .. state.strength
end
function attackCreature(creature)
local total = state.strength + state.resources.power.current
if creature.defeated then
state.message = creature.name .. " is already defeated"
return
end
if total >= creature.hp then
creature.defeated = true
state.score = state.score + creature.points
addResource("food", creature.reward_food)
state.resources.power.current = 0
state.strength = 0
state.message = "Defeated " .. creature.name .. "! +" .. creature.points .. " pts"
else
state.message = "Need " .. creature.hp .. " ATK. You have " .. total
end
end
function endTurn()
if state.game_over then return end
engine.moveAllCards(state.zones.hand, state.zones.discard)
local keep = {}
for _, card in ipairs(state.zones.tableau.cards) do
if card.persistent then
table.insert(keep, card)
else
table.insert(state.zones.discard.cards, card)
end
end
state.zones.tableau.cards = keep
engine.layoutZone(state.zones.tableau)
engine.layoutZone(state.zones.discard)
state.strength = 0
state.turn = state.turn + 1
local all_defeated = true
for _, c in ipairs(state.zones.creatures.cards) do
if not c.defeated then all_defeated = false end
end
if all_defeated then
state.game_over = true
state.message = "All creatures defeated! Final score: " .. state.score
return
end
drawHand()
state.message = "Turn " .. state.turn .. " - Drag cards to Tableau, click creatures to attack"
end
function love.update(dt)
state.mx, state.my = love.mouse.getPosition()
if state.drag.card then
state.drag.card.x = state.mx - state.drag.ox
state.drag.card.y = state.my - state.drag.oy
end
end
function love.draw()
love.graphics.setColor(0.7, 0.7, 0.8)
love.graphics.print(
config.title .. " | Turn: " .. state.turn ..
" | Score: " .. state.score ..
" | Strength: " .. state.strength .. " + " .. state.resources.power.current .. "P",
30, 10
)
local res_list = {}
for _, def in ipairs(config.resources) do
table.insert(res_list, state.resources[def.key])
end
engine.drawResources(res_list, 30, 280)
local dragged = state.drag.card
for _, name in ipairs({"creatures", "tableau", "deck", "discard", "hand"}) do
engine.drawZone(state.zones[name], dragged)
end
local btn_x, btn_y, btn_w, btn_h = 1020, 620, 130, 50
local btn_hover = engine.pointInRect(state.mx, state.my, btn_x, btn_y, btn_w, btn_h)
engine.drawButton("End Turn", btn_x, btn_y, btn_w, btn_h, btn_hover)
local atk_x, atk_y, atk_w, atk_h = 1020, 420, 130, 50
love.graphics.setColor(0.15, 0.15, 0.2, 0.8)
love.graphics.rectangle("fill", atk_x, atk_y, atk_w, atk_h, 6, 6)
love.graphics.setColor(1, 0.85, 0.2)
local total_atk = state.strength + state.resources.power.current
love.graphics.printf("ATK: " .. total_atk, atk_x, atk_y + atk_h / 2 - 8, atk_w, "center")
love.graphics.setColor(1, 1, 0.7)
love.graphics.print(state.message, 30, 330)
if state.game_over then
love.graphics.setColor(0, 0, 0, 0.7)
love.graphics.rectangle("fill", 0, 0, 1280, 800)
love.graphics.setColor(1, 1, 0.5)
love.graphics.printf("GAME OVER", 0, 300, 1280, "center")
love.graphics.setColor(1, 1, 1)
love.graphics.printf("Final Score: " .. state.score .. " in " .. state.turn .. " turns", 0, 340, 1280, "center")
love.graphics.printf("Press R to restart", 0, 380, 1280, "center")
end
if dragged then
engine.drawCard(dragged, true)
end
end
function love.mousepressed(x, y, button)
if button ~= 1 or state.game_over then return end
if engine.pointInRect(x, y, 1020, 620, 130, 50) then
endTurn()
return
end
local creature = engine.findCardInZone(state.zones.creatures, x, y)
if creature and creature.type == "creature" then
attackCreature(creature)
return
end
local card = engine.findCardInZone(state.zones.hand, x, y)
if card then
state.drag.card = card
state.drag.origin = state.zones.hand
state.drag.ox = x - card.x
state.drag.oy = y - card.y
engine.removeCard(state.zones.hand, card)
return
end
end
function love.mousereleased(x, y, button)
if button ~= 1 or not state.drag.card then return end
local card = state.drag.card
local dropped = false
local tz = state.zones.tableau
if engine.pointInRect(x, y, tz.x, tz.y, tz.w, tz.h) then
engine.addCard(tz, card)
playCard(card)
dropped = true
end
if not dropped then
engine.addCard(state.drag.origin, card)
end
state.drag.card = nil
state.drag.origin = nil
end
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
if key == "r" then
love.load()
end
end
Secret mission
Add a Card Market
The base game cycles the same 8 starter cards forever. A real deck-builder lets you buy new, more powerful cards during the game. Your mission is to add a market zone where you spend resources to purchase cards that enter your deck on the next reshuffle.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The board-game-engine project folder containing conf.lua, game_config.lua, engine.lua, and main.lua.
- LOVE2D 11.5 installation (pre-existing).
- VS Code (pre-existing).
Keep everything running
No action needed. Choose this if you are still actively building on the engine or want to try creating a new game config.
- Your project folder stays where it is.
- You can run love . from the project folder any time to launch the game.
- There are no running processes or background services to worry about.
Pause - I'll come back to this later
There are no running processes to shut down. Your project is just a folder of Lua files on disk.
- Close the LOVE2D game window if it is still open.
- Close any open files in VS Code to free up memory.
- Your board-game-engine folder will be ready whenever you come back.
Delete - I don't want to use this again
Remove all project files and start fresh.
- Close the LOVE2D game window if it is still open.
- Delete the entire board-game-engine folder from your file system.
- Keep your LOVE2D installation if you plan to use it for other projects.
Mission accomplished
That's a Wrap!
You just built a data-driven card game engine in LOVE2D from scratch. The engine renders cards, manages zones, handles drag-and-drop input, tracks resources, and runs a full turn-based game loop. On top of it, you built a complete playable game called Dino Hunt.
Here's what you learned:
- Architected a data-driven game system that cleanly separates reusable engine code (engine.lua) from game-specific data (game_config.lua), so swapping the config file creates an entirely different card game.
- Built a zone management system with drag-and-drop card interaction that handles deck, hand, discard, and tableau layouts with snap-to-zone validation.
- Created a turn-based game loop with resource tracking, deck cycling, persistent card activation, and creature combat with attack resolution and scoring.
- Added a card market with a buy mechanic, completing the full deck-building loop where purchased cards enter your discard pile and eventually cycle into your hand.
Ready to quiz yourself?