Dinoblivion Solo Game Rules in LOVE2D

Implement clan activation, RAGE mechanic, and scoring for a solo card game.

Introduction

30 Second Summary

Board games come alive when rules create real tension. Every card you play should feel like a meaningful decision, not just dragging something onto a pile.

In this project, you will implement the full Dinoblivion solo game rules in LOVE2D. You will build a clan card activation system, a RAGE mechanic that punishes slow play, and a complete end-of-game scoring algorithm.

What You'll Build

A fully playable Dinoblivion solo mode where clan cards must be activated to perform actions, the RAGE mechanic floods territories when your deck empties, and victory points are tallied from families, totems, dinosaurs, and eggs.

By the end of this project, you'll have:

  • A clan card activation system where clicking a card presents five distinct actions (use power, support action, hunt left, hunt right, attack dino).
  • A RAGE mechanic that reveals cards from the jungle to territories based on dinosaur level whenever the player deck empties.
  • A complete end-of-game scoring screen that calculates victory points from ami families, totem symbols, defeated dinosaurs, and revealed eggs.
  • Secret Mission: implement the full enemy encounter system where enemies accumulate during RAGE and can be defeated by spending resources.

Are there any prerequisites?

This is Part 2 of the Dinoblivion series. You need to have completed Part 1 (Build a Deck-Building Card Game in LOVE2D) with the lua_dinoB repository cloned, running, and ready to go.

Add Cave, Totem, and Egg Bank Zones

Your Dinoblivion game from Part 1 has the core zones for deck-building: deck, hand, jungle, and territories. The solo rules from regleslimpides.txt require three additional zones that don't exist yet.

In this step, you'll add the cave (stores 1 card between turns), the totem zone (persistent action cards), and the egg bank (12 face-down egg rewards).

In this step, get ready to:
  • Add cave, totem, and egg bank layout constants and zone factories to zones.lua.
  • Initialize egg tokens and new zone state tracking in main.lua.
  • Render the three new zones with labels and counts in drawGame().
Add new zone position constants to zones.lua

Every zone in Dinoblivion is positioned on a 1280x800 virtual canvas. The existing constants define pixel positions for the deck, hand, jungle, and other Part 1 zones.

  • In zones.lua, find the line layout.BTN_H = 50 near the top of the file.
  • Add the following new zone position constants directly below it:
-- New zone positions
layout.CAVE_X = 1020
layout.CAVE_Y = 220
layout.CAVE_W = 110
layout.CAVE_H = 155

layout.TOTEM_X = 1140
layout.TOTEM_Y = 50
layout.TOTEM_W = 130
layout.TOTEM_H = 320

layout.EGG_X = 1020
layout.EGG_Y = 50
layout.EGG_W = 110
layout.EGG_H = 60

What do these constants define?

Each group of four values (X, Y, W, H) defines a rectangle on the virtual canvas where a zone will be drawn.

  • Cave sits at the right edge (x=1020) alongside the upper territory row. It is the same height as a territory zone.
  • Totem is a tall column (320px) at the far right (x=1140). It needs vertical space because totems stack up over the course of a game.
  • Egg bank is a compact zone (60px tall) at the top right. Eggs don't need much space since they're simple tokens, not full cards.

You won't see a change yet. The next chunk wires these constants into actual zone objects.

Add the three new zones to createGameZones()

The createGameZones() function returns a table of all game zones. Each zone is created with engine.newZone() using the layout constants you just added.

  • Scroll down to the createGameZones() function in zones.lua.
  • Find the trophies zone entry:
        trophies = engine.newZone(L.SIDE_X, L.DINO_Y, 230, 160, "row", "Trophees"),
  • Add the following three new zone entries directly below the trophies line:
        cave = engine.newZone(L.CAVE_X, L.CAVE_Y, L.CAVE_W, L.CAVE_H, "stack", "Grotte"),
        totem = engine.newZone(L.TOTEM_X, L.TOTEM_Y, L.TOTEM_W, L.TOTEM_H, "stack", "Totems"),
        egg_bank = engine.newZone(L.EGG_X, L.EGG_Y, L.EGG_W, L.EGG_H, "stack", "Oeufs"),

What do these zone entries do?

  • All three zones use the "stack" layout type because they hold overlapping piles, not side-by-side rows.
  • The French labels ("Grotte", "Totems", "Oeufs") match the game's French-language rules and card database.
  • The L shorthand references the layout table, matching the pattern used by every other zone in this function.
  • Save zones.lua.
  • Run your game to check for errors by running:
love .

The game should launch without errors. You won't see the new zones drawn yet because drawGame() hasn't been updated to render them.

Seeing a Lua error on launch?

  • Check that each new constant line ends with no trailing comma. Only the zone entries inside the return table use commas.
  • Verify that the three new zone entries are inside the return { ... } block, not after its closing brace.
  • Make sure you didn't accidentally delete the comma at the end of the trophies line.

Why is my game crashing after updating zones.lua?

✔️ Awesome, I've got everything!

Great. Double check you've saved zones.lua before moving on.

ⓧ I'd like to double check the full code

Here is the complete zones.lua file with all your changes so far.

-- zones.lua
-- Source de verite des constantes de layout et des factories de zones Dinoblivion.
-- Canvas virtuel : 1280x800

local engine = require("engine")
local config = require("game_config")

local layout = {}

-- ── Constantes de layout ──────────────────────────────────────
layout.UPPER_Y = 220
layout.UPPER_H = 155
layout.DINO_Y = 50
layout.DINO_H = 145
layout.SEP_X = 530
layout.RES_Y = 382
layout.MSG_Y = 424
layout.TAB_Y = 472
layout.TAB_H = 132
layout.HAND_Y = 472 + 132 + 18
layout.HAND_H = 140
layout.SIDE_X = 1020
layout.ATK_Y = 472 + 4
layout.BTN_Y = 622 + 22
layout.BTN_W = 130
layout.BTN_H = 50

-- New zone positions
layout.CAVE_X = 1020
layout.CAVE_Y = 220
layout.CAVE_W = 110
layout.CAVE_H = 155

layout.TOTEM_X = 1140
layout.TOTEM_Y = 50
layout.TOTEM_W = 130
layout.TOTEM_H = 320

layout.EGG_X = 1020
layout.EGG_Y = 50
layout.EGG_W = 110
layout.EGG_H = 60

-- ── Factory zones de jeu ──────────────────────────────────────
function layout.createGameZones()
    local L = layout
    return {
        deck = engine.newZone(30, L.HAND_Y, 100, L.HAND_H, "stack", "Deck"),
        hand = engine.newZone(150, L.HAND_Y, 700, L.HAND_H, "fan", "Main"),
        discard = engine.newZone(870, L.HAND_Y, 100, L.HAND_H, "stack", "Defausse"),
        tableau = engine.newZone(30, L.TAB_Y, 940, L.TAB_H, "row", "Tableau"),
        territory_left = engine.newZone(10, L.UPPER_Y, 420, L.UPPER_H, "row", "Territoire G"),
        jungle = engine.newZone(440, L.UPPER_Y, 130, L.UPPER_H, "stack", "Jungle"),
        territory_right = engine.newZone(580, L.UPPER_Y, 420, L.UPPER_H, "row", "Territoire D"),
        trophies = engine.newZone(L.SIDE_X, L.DINO_Y, 230, 160, "row", "Trophees"),
        cave = engine.newZone(L.CAVE_X, L.CAVE_Y, L.CAVE_W, L.CAVE_H, "stack", "Grotte"),
        totem = engine.newZone(L.TOTEM_X, L.TOTEM_Y, L.TOTEM_W, L.TOTEM_H, "stack", "Totems"),
        egg_bank = engine.newZone(L.EGG_X, L.EGG_Y, L.EGG_W, L.EGG_H, "stack", "Oeufs"),
    }
end

-- ── Factory piles dinos ───────────────────────────────────────
local function shuffle(pool)
    for i = #pool, 2, -1 do
        local j = love.math.random(i)
        pool[i], pool[j] = pool[j], pool[i]
    end
end

local function buildPile(pool, count, x, y, w, h, level)
    local zone = engine.newZone(x, y, w, h, "stack", "")
    for i = count, 1, -1 do
        local card = engine.newCreature(config.make_creature(pool[i]))
        card.face_up = false
        engine.addCard(zone, card)
    end
    if #zone.cards > 0 then
        zone.cards[#zone.cards].face_up = true
    end
    return { zone = zone, level = level }
end

function layout.createDinoPiles()
    local L = layout
    local l1_pool, l2_pool = {}, {}
    for _, db in ipairs(config.get_cards_of_type("dino_l1")) do
        for _ = 1, (db.qty or 1) do table.insert(l1_pool, db) end
    end
    for _, db in ipairs(config.get_cards_of_type("dino_l2")) do
        for _ = 1, (db.qty or 1) do table.insert(l2_pool, db) end
    end
    shuffle(l1_pool)
    shuffle(l2_pool)
    return {
        buildPile(l1_pool, config.solo_dino_left_count, 150, L.DINO_Y, 160, L.DINO_H, 1),
        buildPile(l2_pool, config.solo_dino_right_count, 700, L.DINO_Y, 160, L.DINO_H, 2),
    }
end

return layout
Initialize egg tokens and state tracking in initGame()

The solo rules use 12 eggs drawn from a shuffled pool of 24. Each egg has a point value (1 or 2) that counts toward your final score.

  • Open main.lua and find the initGame() function.
  • Add the following egg bank initialization code at the end of initGame(), before the function's closing end:
    -- Build the egg pool: 12 green (1pt), 8 red (2pt), 4 blue (2pt indestructible)
    local egg_pool = {}
    for _ = 1, 12 do table.insert(egg_pool, { value = 1, color = "vert" }) end
    for _ = 1, 8 do table.insert(egg_pool, { value = 2, color = "rouge" }) end
    for _ = 1, 4 do table.insert(egg_pool, { value = 2, color = "bleu", indestructible = true }) end

    -- Shuffle the full pool using Fisher-Yates
    for i = #egg_pool, 2, -1 do
        local j = love.math.random(i)
        egg_pool[i], egg_pool[j] = egg_pool[j], egg_pool[i]
    end

    -- Take the first 12 eggs for solo mode
    state.egg_bank = {}
    for i = 1, 12 do
        table.insert(state.egg_bank, egg_pool[i])
    end

    -- Track eggs the player has collected (starts empty)
    state.eggs_collected = {}

What does this code do?

  • The full Dinoblivion egg pool has 24 eggs in three colors. Green eggs ("vert") are worth 1 point. Red ("rouge") and blue ("bleu") are worth 2 points each.
  • Blue eggs are marked indestructible = true because certain card effects can destroy eggs, but blue ones are immune.
  • The pool is shuffled, then only 12 are selected for solo mode. This randomness means every playthrough has a different egg distribution.
  • state.eggs_collected starts empty. Eggs move here when the player defeats dinosaurs later.
  • Save main.lua and run the game:
love .

The game should launch without errors. You won't see the eggs drawn on screen yet. That comes in the next substep.

Seeing an "attempt to index nil value" error?

  • Make sure the egg initialization code is inside initGame(), not outside or after its closing end.
  • Verify that state is already defined earlier in initGame() before your new code references it.

Help me debug my egg bank initialization in main.lua.

Render the new zones in drawGame()

The zones exist in memory but nothing draws them on screen. Each zone needs its rectangle drawn by the engine plus a text label showing its current status.

  • In main.lua, find the drawGame() function.
  • Add the following rendering code after the existing zone draw calls (e.g. after the trophies zone is drawn):
    -- Draw cave zone with status label
    engine.drawZone(state.zones.cave)
    local cave = state.zones.cave
    local cave_label = #cave.cards > 0 and "1 carte" or "Vide"
    love.graphics.printf("Grotte: " .. cave_label,
        cave.x, cave.y + cave.h + 4, cave.w, "center")

    -- Draw totem zone with count
    engine.drawZone(state.zones.totem)
    local totem = state.zones.totem
    love.graphics.printf("Totems: " .. #totem.cards,
        totem.x, totem.y + totem.h + 4, totem.w, "center")

    -- Draw egg bank with remaining count
    engine.drawZone(state.zones.egg_bank)
    local egg_zone = state.zones.egg_bank
    love.graphics.printf("Oeufs: " .. #state.egg_bank,
        egg_zone.x, egg_zone.y + egg_zone.h + 4, egg_zone.w, "center")

What does this rendering code do?

  • engine.drawZone() handles drawing the zone's background rectangle and any cards it contains.
  • love.graphics.printf() renders a text label positioned 4 pixels below each zone. The "center" alignment keeps labels tidy under their zones.
  • The cave label shows "Vide" or "1 carte" since the cave can only hold one card at a time.
  • The egg count references state.egg_bank (the data array) rather than the zone's cards, since eggs are tracked as simple token objects separate from the card engine.
  • Save main.lua and run the game:
love .

You should see three new labeled zones on the right side of the canvas. "Grotte: Vide" appears at the mid-right. "Totems: 0" appears at the far-right top. "Oeufs: 12" appears at the top-right above the cave.

Don't see the new zone labels?

  • Check that your draw code is inside the drawGame() function, not inside a different draw function or outside all functions.
  • If labels appear off-screen or overlapping, verify the zone position constants in zones.lua match the values from the first substep exactly.
  • If the egg count shows 0 instead of 12, make sure the egg initialization code runs before drawGame() is called. It should be inside initGame().

Help me debug why my new zones aren't rendering in drawGame().

Your game layout now has all the zones needed for solo play. Next up, you'll build the clan card activation system that lets players choose from five different actions when clicking a card.

Implement Clan Card Activation System

Your game board now has cave, totem, and egg bank zones ready and waiting. In this step, you will build the core mechanic that makes Dinoblivion tick: clan card activation.

In the base game from Part 1, clicking a card simply added its strength and food to your totals. The real Dinoblivion rules are more strategic. Each clan card must be activated to perform exactly one action: use its power, support an action card, hunt left, hunt right, or attack a dinosaur.

In this step, get ready to:
  • Create solo_rules.lua with activation actions, available action logic, and a hunt function.
  • Add an action selection popup to main.lua so clicking a clan card presents a menu of choices.
  • Wire the Use Power and Hunt actions, and update engine.lua to visually indicate activated cards.
Create the solo rules module

The activation system lives in its own module so game rules stay separate from rendering code in main.lua. This also lets you add a duel_rules.lua later for multiplayer without touching solo logic.

  • Create a new file called solo_rules.lua in the root of your lua_dinoB project folder (the same folder where main.lua lives).
  • Add the module header, requires, and the ACTIONS table by pasting this code:
-- solo_rules.lua
-- Core solo game rules for Dinoblivion: activation, RAGE, scoring.

local engine = require("engine")
local config = require("game_config")
local card_powers = require("card_powers")

local solo = {}

-- All possible actions a clan card can perform when activated
solo.ACTIONS = {
    USE_POWER    = "use_power",
    SUPPORT      = "support_action",
    HUNT_LEFT    = "hunt_left",
    HUNT_RIGHT   = "hunt_right",
    ATTACK_DINO  = "attack_dino",
    FORM_AMI     = "form_ami",
}

return solo

What does this code do?

The module requires engine, game_config, and card_powers because activation logic needs to move cards between zones, look up card data, and trigger power functions.

The ACTIONS table maps each action to a string key. Using named constants like solo.ACTIONS.HUNT_LEFT instead of raw strings prevents typos from causing silent bugs.

  • Save solo_rules.lua.
  • Confirm the file exists by running this in your terminal from the lua_dinoB folder:
ls solo_rules.lua

You should see solo_rules.lua listed in the output.

Next, the module needs a function that figures out which actions are available for a given clan card. Not every card can do every action.

  • In solo_rules.lua, find the line that says return solo.
  • Add the get_available_actions function above that line by pasting this code:
function solo.get_available_actions(card, state)
    local actions = {}
    local db_entry = config.get_card(card.id)
    if not db_entry then return actions end

    -- Use Power: only if card has a card_power and is type clan
    if db_entry.card_power and db_entry.card_type == "clan" then
        table.insert(actions, { key = solo.ACTIONS.USE_POWER, label = "Pouvoir" })
    end

    -- Support Action: only if player has action cards in hand
    local has_action_in_hand = false
    for _, c in ipairs(state.zones.hand.cards) do
        if c.id ~= card.id then
            local cdb = config.get_card(c.id)
            if cdb and cdb.card_type == "action" then
                has_action_in_hand = true
                break
            end
        end
    end
    if has_action_in_hand then
        table.insert(actions, { key = solo.ACTIONS.SUPPORT, label = "Activer Action" })
    end

    -- Hunt: only if card has strength and jungle is not empty
    if (db_entry.strength or 0) > 0 and #state.zones.jungle.cards > 0 then
        table.insert(actions, { key = solo.ACTIONS.HUNT_LEFT, label = "Chasser Gauche" })
        table.insert(actions, { key = solo.ACTIONS.HUNT_RIGHT, label = "Chasser Droite" })
    end

    -- Attack Dino: available if force accumulated or dino tokens in reserve
    if state.strength > 0 or state.resources.dino_tokens.current > 0 then
        table.insert(actions, { key = solo.ACTIONS.ATTACK_DINO, label = "Attaquer Dino" })
    end

    return actions
end

What does this function do?

This function checks the game state and the card's database entry to build a list of actions the player can take. Each action is only offered when it makes sense.

  • Use Power only appears if the card actually has a card_power defined in the database.
  • Support Action only appears if there is at least one action card in the player's hand (excluding the selected clan card itself).
  • Hunt Left/Right only appears if the card has strength greater than 0 and the jungle still has cards to reveal.
  • Attack Dino only appears if accumulated force or dino tokens are available.
  • Save solo_rules.lua.

You won't see a change yet. The next chunk adds the function that actually activates a card.

When a clan card is activated, it needs to move from the hand to the tableau, get flagged as activated, and contribute its strength to the player's force pool.

  • In solo_rules.lua, add the activate_clan_card function below get_available_actions (still above return solo):
function solo.activate_clan_card(card, state)
    -- Mark card as activated and move it from hand to tableau
    card.activated = true
    engine.removeCard(state.zones.hand, card)
    engine.addCard(state.zones.tableau, card)
    -- Add this card's strength to the player's accumulated force
    state.strength = state.strength + (card.strength or 0)
end

What does this function do?

Setting card.activated = true flags this card so the rendering code can draw it rotated 90 degrees (you will add that visual later in this step).

The card moves from hand to tableau because an activated card is now "in play." Its strength gets added to state.strength, which accumulates force for attacking dinosaurs.

  • Save solo_rules.lua.

You won't see a visible change yet. The next substep connects this module to main.lua so clicking a card triggers the activation flow.

Seeing a syntax error when you run the game?

Make sure every function definition ends with end and that return solo is the very last line of the file.

Check that your string values in solo.ACTIONS all have matching opening and closing quotes.

Help me debug a syntax error in solo_rules.lua

Add the action selection popup to main.lua

The solo rules module knows which actions are available, but the player needs a way to choose one. You will add a popup that appears when a clan card in hand is clicked, showing clickable buttons for each valid action.

  • Open main.lua in VS Code.
  • At the top of the file, find your other require statements.
  • Add this line alongside them to import the solo rules module:
local solo = require("solo_rules")

What does this do?

This loads your new solo_rules.lua module and stores it in the local variable solo. You will call functions like solo.get_available_actions() throughout main.lua.

The game state needs two new fields to track the activation flow. One tracks which clan card the player selected, and the other accumulates force from activated cards.

  • In main.lua, scroll to your initGame() function.
  • Add these two lines inside the function, near where you initialize other state fields (like state.turn or state.message):
-- Track which clan card is waiting for action selection (nil = no popup)
state.pending_activation = nil
-- Accumulated force from activated clan cards this turn
state.strength = 0

What do these fields do?

  • state.pending_activation holds the card object when the player clicks a clan card. When it is not nil, the popup menu renders on screen.
  • state.strength accumulates force points as clan cards are activated throughout a turn. This total determines whether the player can attack dinosaurs.
  • Save main.lua.

You won't see a change yet. The next chunk wires the click handler and popup rendering.

Now you need to intercept clicks on clan cards in the hand and show the action popup. The existing love.mousepressed function currently handles card clicks by calling playCard() directly. You will replace that with activation logic.

  • In main.lua, find your love.mousepressed function.
  • Locate the section that detects a click on a card in the hand zone. It currently calls something like playCard(card).
  • Replace the playCard(card) call with the following clan card detection and popup trigger logic:
-- Check if clicked card is a clan card for activation
local db_entry = config.get_card(card.id)
if db_entry and db_entry.card_type == "clan" then
    -- Set this card as pending activation and build the action list
    state.pending_activation = card
    state.pending_actions = solo.get_available_actions(card, state)
else
    -- Non-clan cards (action cards) cannot be activated directly
    state.message = "Seules les cartes Clan peuvent etre activees."
end

What does this change?

Instead of immediately playing a card, this code checks whether the clicked card is a clan card. If it is, the card is stored in state.pending_activation and its available actions are computed.

The popup rendering code (which you will add next) checks state.pending_activation to decide whether to draw the action menu.

You also need to handle clicks on the popup buttons themselves, and dismiss the popup if the player clicks elsewhere.

  • Inside the same love.mousepressed function, add this popup click handler BEFORE the hand-card detection block (so popup clicks are caught first):
-- Handle clicks on the action selection popup
if state.pending_activation and state.pending_actions then
    local popup_x, popup_y = 500, 250
    local btn_w, btn_h = 200, 36
    local clicked_action = nil

    for i, action in ipairs(state.pending_actions) do
        local btn_y = popup_y + (i - 1) * (btn_h + 6)
        if mx >= popup_x and mx <= popup_x + btn_w
           and my >= btn_y and my <= btn_y + btn_h then
            clicked_action = action
            break
        end
    end

    if clicked_action then
        -- Dispatch the selected action (wired in the next substep)
        solo.activate_clan_card(state.pending_activation, state)
        state.pending_activation = nil
        state.pending_actions = nil
        return
    else
        -- Clicked outside popup: dismiss it
        state.pending_activation = nil
        state.pending_actions = nil
        return
    end
end

What does this code do?

This block runs first in love.mousepressed when a popup is visible. It checks each button's bounding rectangle against the mouse coordinates.

If a button is clicked, it activates the clan card and clears the popup. If the click misses all buttons, the popup is dismissed. The return prevents the click from falling through to other handlers.

Finally, the popup needs to be drawn on screen when a clan card is waiting for action selection.

  • In main.lua, find your drawGame() function.
  • Add this popup rendering code at the end of the function (so it draws on top of everything else):
-- Draw action selection popup when a clan card is pending activation
if state.pending_activation and state.pending_actions then
    local popup_x, popup_y = 500, 250
    local btn_w, btn_h = 200, 36

    -- Semi-transparent background overlay
    love.graphics.setColor(0, 0, 0, 0.5)
    love.graphics.rectangle("fill", popup_x - 10, popup_y - 40,
                            btn_w + 20, #state.pending_actions * (btn_h + 6) + 50)

    -- Popup title showing the selected card's name
    love.graphics.setColor(1, 1, 0.6)
    love.graphics.printf("Activer: " .. (state.pending_activation.name or "?"),
                         popup_x, popup_y - 30, btn_w, "center")

    -- Draw one button per available action
    for i, action in ipairs(state.pending_actions) do
        local btn_y = popup_y + (i - 1) * (btn_h + 6)
        love.graphics.setColor(0.25, 0.35, 0.25)
        love.graphics.rectangle("fill", popup_x, btn_y, btn_w, btn_h, 4)
        love.graphics.setColor(1, 1, 1)
        love.graphics.printf(action.label, popup_x, btn_y + 8, btn_w, "center")
    end
end

What does this rendering code do?

A dark semi-transparent rectangle acts as the popup background. The selected card's name is displayed as a title at the top.

Each available action gets a green button with its label centered inside. The button positions match the click detection coordinates in love.mousepressed so clicks land on the right action.

  • Save main.lua.
  • Run the game by executing this from your lua_dinoB folder:
love .
  • Start a solo game and click a clan card in your hand.

You should see a dark popup appear in the center of the screen with action buttons like Pouvoir and Chasser Gauche. Clicking a button activates the card and moves it to the tableau, but the specific action effects are not wired yet.

Popup not appearing when you click a card?

  • Make sure the popup click handler appears BEFORE the hand-card detection in love.mousepressed. If the order is reversed, clicks get consumed before reaching the popup.
  • Verify that you replaced the playCard(card) call with the new clan card detection code. If both exist, the old code runs first and the popup never triggers.
  • Check the LOVE2D terminal output for errors. A missing require for solo_rules will crash before the popup code runs.

Help me debug why my action popup isn't showing up

Wire actions and render activation state

The popup buttons trigger activation, but the specific action effects are not connected yet. In this substep, you will wire Use Power and Hunt to their game logic, then add a visual indicator so players can see which cards are activated.

Hunting is one of the most common actions in Dinoblivion. It reveals cards from the jungle onto a territory based on the clan card's strength.

  • Open solo_rules.lua.
  • Add the hunt function below activate_clan_card (above return solo) by pasting this code:
-- ─── Hunt Logic ───────────────────────────────────────────────────────────────

function solo.hunt(card, territory, state)
    local db_entry = config.get_card(card.id)
    local force = db_entry and db_entry.strength or card.strength or 0
    if force <= 0 then return 0, "Pas de Force pour chasser." end

    local jungle = state.zones.jungle
    local food_gained = 0
    local revealed = 0

    -- Reveal N cards from jungle (N = card strength)
    for _ = 1, force do
        if #jungle.cards == 0 then break end
        local c = table.remove(jungle.cards, #jungle.cards)
        c.face_up = true

        local c_db = config.get_card(c.id)
        if c_db and c_db.type == "enemy" then
            -- Enemy during hunt: return to bottom of jungle, no effect
            c.face_up = false
            table.insert(jungle.cards, 1, c)
        else
            -- Normal card: place on the chosen territory
            engine.addCard(territory, c)
            food_gained = food_gained + (c_db and c_db.food_reward or 0)
            revealed = revealed + 1
        end
    end

    engine.layoutZone(jungle)
    -- Add any food gained to player resources
    if food_gained > 0 then
        local res = state.resources.food
        res.current = math.min(res.current + food_gained, res.max)
    end

    return revealed, food_gained
end

What does the hunt function do?

The function reveals cards from the top of the jungle equal to the clan card's strength value. Each revealed card is placed face-up on the chosen territory (left or right).

Enemy cards get special treatment during a hunt. If an enemy is revealed, it is returned face-down to the bottom of the jungle with no effect. This is different from the RAGE mechanic (which you will build in a later step), where enemies stay on the territory.

Any food rewards from revealed cards are added to the player's food resource, capped at the maximum.

  • Save solo_rules.lua.

You won't see a change yet. The next chunk wires this function to the popup buttons in main.lua.

Now you need to dispatch the correct logic based on which action button the player clicks. The popup click handler currently calls solo.activate_clan_card for every action, but it does not trigger the action-specific effect.

  • In main.lua, find the popup click handler inside love.mousepressed.
  • Find this line inside the if clicked_action then block:
        solo.activate_clan_card(state.pending_activation, state)
        state.pending_activation = nil
        state.pending_actions = nil
        return
  • Replace those lines with the full action dispatch logic:
        local card = state.pending_activation
        local action_key = clicked_action.key

        if action_key == solo.ACTIONS.USE_POWER then
            -- Activate the card, then call its power function
            solo.activate_clan_card(card, state)
            local db_entry = config.get_card(card.id)
            if db_entry and db_entry.card_power then
                local power_fn = card_powers[db_entry.card_power]
                if power_fn then
                    -- Build a state object compatible with card_powers format
                    local gs = {
                        food = state.resources.food.current,
                        ami = state.resources.ami.current,
                        dino_tokens = state.resources.dino_tokens.current,
                        attack_force = state.strength,
                        message = "",
                        pending_draw = 0,
                    }
                    power_fn(gs, card)
                    -- Apply power results back to game state
                    state.resources.food.current = math.min(gs.food, state.resources.food.max)
                    state.resources.ami.current = math.min(gs.ami, state.resources.ami.max)
                    state.resources.dino_tokens.current = math.min(gs.dino_tokens, state.resources.dino_tokens.max)
                    state.strength = gs.attack_force
                    state.message = gs.message or state.message
                    if (gs.pending_draw or 0) > 0 then
                        engine.dealCards(state.zones.deck, state.zones.hand, gs.pending_draw)
                    end
                end
            end

        elseif action_key == solo.ACTIONS.HUNT_LEFT then
            -- Hunt left territory, then activate the card
            local revealed, food = solo.hunt(card, state.zones.territory_left, state)
            solo.activate_clan_card(card, state)
            state.message = revealed .. " carte(s) chassees a gauche, +" .. food .. " nourriture"

        elseif action_key == solo.ACTIONS.HUNT_RIGHT then
            -- Hunt right territory, then activate the card
            local revealed, food = solo.hunt(card, state.zones.territory_right, state)
            solo.activate_clan_card(card, state)
            state.message = revealed .. " carte(s) chassees a droite, +" .. food .. " nourriture"

        else
            -- Other actions (Support, Attack Dino, Form Ami) wired in later steps
            solo.activate_clan_card(card, state)
            state.message = "Action en cours: " .. (clicked_action.label or "?")
        end

        state.pending_activation = nil
        state.pending_actions = nil
        return

What does this dispatch code do?

  • USE_POWER activates the card, looks up its power function in card_powers, builds a compatible state object, calls the power, then applies the results (food, ami, dino tokens, draw) back to the real game state.
  • HUNT_LEFT and HUNT_RIGHT call solo.hunt() targeting the appropriate territory zone, then activate the card. The status message shows how many cards were revealed and food gained.
  • Other actions (Support, Attack Dino, Form Ami) fall into the else branch for now. You will wire them in later steps.
  • Save main.lua.

You won't see a visible difference yet because activated cards still look the same as regular cards. The next chunk adds the visual indicator.

In the physical Dinoblivion game, players rotate activated clan cards 90 degrees to show they have been used. You will add a visual indicator on the card to replicate this.

  • Open engine.lua.
  • Find the drawCard function. This is where each card's rectangle, name, and stats are rendered.
  • At the end of the drawCard function (just before the function's closing end), add this activation indicator code:
    -- Draw activation indicator (90-degree rotation visual)
    if card.activated then
        love.graphics.setColor(1, 0.8, 0, 0.7)
        -- Draw a small rotated rectangle in the top-right corner
        love.graphics.push()
        love.graphics.translate(card.x + card.w - 14, card.y + 14)
        love.graphics.rotate(math.rad(45))
        love.graphics.rectangle("fill", -8, -4, 16, 8)
        love.graphics.pop()
        -- Draw ACT label
        love.graphics.setColor(1, 0.9, 0.2)
        love.graphics.printf("ACT", card.x, card.y + 2, card.w - 4, "right")
        love.graphics.setColor(1, 1, 1)
    end

What does this indicator do?

When card.activated is true, a yellow diamond shape appears in the top-right corner of the card, plus an ACT label.

The rotated rectangle uses love.graphics.push() and love.graphics.pop() to apply a 45-degree rotation without affecting other drawing operations. This gives a quick visual cue without needing sprite assets.

  • Save engine.lua.
  • Run the game by executing this from your lua_dinoB folder:
love .
  • Start a solo game, click a clan card, and select Chasser Gauche from the popup.

You should see the clan card move to the tableau with a yellow ACT label and diamond indicator. Cards from the jungle should appear face-up on the left territory. The status message shows how many cards were revealed.

Cards not appearing on the territory after hunting?

  • Check that you are passing state.zones.territory_left (not just the string "territory_left") to solo.hunt().
  • Verify the jungle has cards in it. If the jungle zone shows 0 cards, hunting has nothing to reveal.
  • Make sure the selected card has a strength value greater than 0 in card_db.lua. If a clan card has 0 strength, hunt buttons should not appear in the popup.

Help me debug why hunting is not placing cards on the territory

✔️ Awesome, I've got everything!

Great. Double check you have saved solo_rules.lua, main.lua, and engine.lua.

ⓧ I'd like to double check the full code

Here is the full solo_rules.lua as it should look at the end of this step. Compare your file against this and correct any differences.

-- solo_rules.lua
-- Core solo game rules for Dinoblivion: activation, RAGE, scoring.

local engine = require("engine")
local config = require("game_config")
local card_powers = require("card_powers")

local solo = {}

-- All possible actions a clan card can perform when activated
solo.ACTIONS = {
    USE_POWER    = "use_power",
    SUPPORT      = "support_action",
    HUNT_LEFT    = "hunt_left",
    HUNT_RIGHT   = "hunt_right",
    ATTACK_DINO  = "attack_dino",
    FORM_AMI     = "form_ami",
}

function solo.get_available_actions(card, state)
    local actions = {}
    local db_entry = config.get_card(card.id)
    if not db_entry then return actions end

    -- Use Power: only if card has a card_power and is type clan
    if db_entry.card_power and db_entry.card_type == "clan" then
        table.insert(actions, { key = solo.ACTIONS.USE_POWER, label = "Pouvoir" })
    end

    -- Support Action: only if player has action cards in hand
    local has_action_in_hand = false
    for _, c in ipairs(state.zones.hand.cards) do
        if c.id ~= card.id then
            local cdb = config.get_card(c.id)
            if cdb and cdb.card_type == "action" then
                has_action_in_hand = true
                break
            end
        end
    end
    if has_action_in_hand then
        table.insert(actions, { key = solo.ACTIONS.SUPPORT, label = "Activer Action" })
    end

    -- Hunt: only if card has strength and jungle is not empty
    if (db_entry.strength or 0) > 0 and #state.zones.jungle.cards > 0 then
        table.insert(actions, { key = solo.ACTIONS.HUNT_LEFT, label = "Chasser Gauche" })
        table.insert(actions, { key = solo.ACTIONS.HUNT_RIGHT, label = "Chasser Droite" })
    end

    -- Attack Dino: available if force accumulated or dino tokens in reserve
    if state.strength > 0 or state.resources.dino_tokens.current > 0 then
        table.insert(actions, { key = solo.ACTIONS.ATTACK_DINO, label = "Attaquer Dino" })
    end

    return actions
end

function solo.activate_clan_card(card, state)
    -- Mark card as activated and move it from hand to tableau
    card.activated = true
    engine.removeCard(state.zones.hand, card)
    engine.addCard(state.zones.tableau, card)
    -- Add this card's strength to the player's accumulated force
    state.strength = state.strength + (card.strength or 0)
end

-- ─── Hunt Logic ───────────────────────────────────────────────────────────────

function solo.hunt(card, territory, state)
    local db_entry = config.get_card(card.id)
    local force = db_entry and db_entry.strength or card.strength or 0
    if force <= 0 then return 0, "Pas de Force pour chasser." end

    local jungle = state.zones.jungle
    local food_gained = 0
    local revealed = 0

    -- Reveal N cards from jungle (N = card strength)
    for _ = 1, force do
        if #jungle.cards == 0 then break end
        local c = table.remove(jungle.cards, #jungle.cards)
        c.face_up = true

        local c_db = config.get_card(c.id)
        if c_db and c_db.type == "enemy" then
            -- Enemy during hunt: return to bottom of jungle, no effect
            c.face_up = false
            table.insert(jungle.cards, 1, c)
        else
            -- Normal card: place on the chosen territory
            engine.addCard(territory, c)
            food_gained = food_gained + (c_db and c_db.food_reward or 0)
            revealed = revealed + 1
        end
    end

    engine.layoutZone(jungle)
    -- Add any food gained to player resources
    if food_gained > 0 then
        local res = state.resources.food
        res.current = math.min(res.current + food_gained, res.max)
    end

    return revealed, food_gained
end

return solo

Your clan cards now have a proper activation system with Use Power and Hunt fully wired. Next up, you will implement action card support and totem persistence so your cards can chain together and survive between turns.

Action Cards with Clan Support and Totem Persistence

Your clan cards can now be activated and sent on hunts. In the real Dinoblivion rules, clan cards unlock a second trick: they can support action cards. Instead of using a clan card's own power, you sacrifice it to fuel an action card's reward.

Some of those action cards carry totem symbols. Totems are special because they stick around between turns, reactivating every round instead of heading to the discard pile. In this step, you will build the Support Action flow, wire up totem persistence, and add the cave exchange mechanic.

In this step, get ready to:
  • Build the Support Action flow so clan cards can activate action cards from the player's hand.
  • Add totem zone persistence so totem-bearing action cards survive between turns and reactivate each round.
  • Implement end-of-turn cleanup and the cave card exchange mechanic.
Add the Support Action flow

The Support Action works differently from Use Power. When a player activates a clan card as "support," the clan card itself does not fire its power. Instead, it enables an action card from the player's hand to trigger its reward_action through card_powers.

To make this work, you need a bridge that translates your game state into the format card_powers.lua expects, then applies any changes back. These two functions go in solo_rules.lua.

  • In solo_rules.lua, find the line that says return solo at the bottom of the file.
  • Add the Power State Bridge section above return solo by copying the following code:
-- ─── Power State Bridge ───────────────────────────────────────────────────────
-- Translates between main.lua state and card_powers.lua gs format

function solo.build_power_state(state)
    return {
        food = state.resources.food.current,
        ami = state.resources.ami.current,
        dino_tokens = state.resources.dino_tokens.current,
        attack_force = state.strength,
        message = "",
        pending_draw = 0,
        pending_choice = nil,
        pending_destroy = nil,
        pending_egg_gain = 0,
        extra_actions = 0,
        in_play = {},
        totems = state.zones.totem.cards,
    }
end

function solo.apply_power_state(gs, state)
    state.resources.food.current = math.min(gs.food, state.resources.food.max)
    state.resources.ami.current = math.min(gs.ami, state.resources.ami.max)
    state.resources.dino_tokens.current = math.min(gs.dino_tokens, state.resources.dino_tokens.max)
    state.strength = gs.attack_force
    state.message = gs.message or state.message

    if (gs.pending_draw or 0) > 0 then
        engine.dealCards(state.zones.deck, state.zones.hand, gs.pending_draw)
    end

    if (gs.pending_egg_gain or 0) > 0 then
        solo.gain_egg(state, gs.pending_egg_gain)
    end
end

What does this code do?

The card_powers module expects a specific gs (game state) table format. Your main.lua state uses a different structure with nested resource objects.

  • build_power_state() flattens the nested state.resources.food.current structure into the flat gs.food fields that card powers expect.
  • apply_power_state() reads back the modified gs values and writes them into your real state, clamping resources to their max values.
  • Pending effects like pending_draw and pending_egg_gain are resolved immediately during apply, drawing cards or gaining eggs as needed.
  • Save solo_rules.lua.

Now wire the Support Action handler in main.lua. When the player clicks "Activer Action" from the action popup, your game needs to show a secondary popup listing only the action cards in the player's hand.

  • In main.lua, find your popup button handler inside love.mousepressed where you check the selected action key.
  • Add a branch for solo.ACTIONS.SUPPORT. When triggered, set a new state variable state.selecting_action_card = true to show the secondary popup.
  • Build the secondary popup by iterating state.zones.hand.cards and filtering for cards where config.get_card(c.id).card_type == "action".
  • When the player clicks an action card in the secondary popup, call solo.activate_clan_card() on the pending clan card first.
  • Remove the action card from hand with engine.removeCard(state.zones.hand, action_card).
  • Look up the action card's database entry with config.get_card(action_card.id). Check db.has_totem. If true, add the card to state.zones.totem. Otherwise, add it to state.zones.tableau.
  • Apply the action card's reward by looking up db.card_power in card_powers, calling it through solo.build_power_state() and solo.apply_power_state().
  • Reset state.pending_activation and state.selecting_action_card back to nil / false.

Why does the clan card lose its power here?

In the Dinoblivion rules, supporting an action card is a trade-off. The clan card gets activated (it adds its strength to the tableau), but it does NOT fire its own card_power. The action card's reward fires instead.

This forces the player to choose: use a clan card for its power, or sacrifice the power to unlock a potentially more valuable action card reward.

  • Save main.lua.
  • Run the game with love . and start a solo game. Click a clan card in your hand, then select Activer Action. You should see a secondary popup listing only the action cards in your hand.

Seeing an error when clicking Activer Action?

  • Make sure your SUPPORT branch sets state.selecting_action_card before trying to build the action card list. The popup draw code reads this flag to decide what to show.
  • Check that you only call solo.activate_clan_card() AFTER the player selects an action card, not when they first click Activer Action. The clan card should stay in hand until the support is confirmed.
  • If no action cards appear in the secondary popup, verify that your hand actually contains cards with card_type == "action" in card_db.lua.

Help me debug the Support Action handler in my Dinoblivion solo game.

Wire totem activation at the start of each turn

Totem cards are action cards that persist in the totem zone instead of going to discard. Each turn, the player can click a totem to reactivate its power, but only once per turn.

  • In solo_rules.lua, add the Totem Activation section above the Power State Bridge section by copying the following code:
-- ─── Totem Activation ─────────────────────────────────────────────────────────

function solo.activate_totems_start_of_turn(state)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end
end

function solo.activate_single_totem(card, state)
    if card.activated then return false, "Deja active ce tour." end
    card.activated = true

    local db_entry = config.get_card(card.id)
    if db_entry and db_entry.card_power then
        local power_fn = card_powers[db_entry.card_power]
        if power_fn then
            local gs = solo.build_power_state(state)
            local ok = power_fn(gs, card)
            solo.apply_power_state(gs, state)
            return ok, gs.message or ""
        end
    end
    return false, "Pas de pouvoir."
end

What does this code do?

  • activate_totems_start_of_turn() resets every totem card's activated flag to false at the start of each turn. This is what makes totems reusable every round.
  • activate_single_totem() guards against double activation (checks card.activated), then fires the totem's power through the same build_power_state / apply_power_state bridge you just added.
  • Save solo_rules.lua.

Now connect these functions in main.lua so the totem zone is interactive.

  • In your draw-new-hand logic (where you deal cards at the start of a turn), add a call to solo.activate_totems_start_of_turn(state) so totem cards are marked as available each round.
  • In your drawGame() function, render each card in state.zones.totem.cards with a small clickable "Activate" button beside it. Grey out the button if card.activated == true.
  • In love.mousepressed, add a hit test for totem cards. When a totem card is clicked, call solo.activate_single_totem(card, state) and update the status message with the returned message string.
  • Save main.lua.
  • Run the game with love . and play until you can support an action card that has a totem. The card should appear in the Totems zone on the right side of the screen instead of the tableau.

Totem card going to tableau instead of the totem zone?

  • In your SUPPORT handler in main.lua, make sure the has_totem check uses the database entry (config.get_card(action_card.id)), not the card object itself. The has_totem field lives in the database definition.
  • Verify that your test action card actually has has_totem = true in card_db.lua. Not all action cards are totem cards.

Help me debug why totem cards are not going to the totem zone in Dinoblivion.

Handle end-of-turn cleanup and cave exchange

When the turn ends, regular tableau cards go to discard. Totem cards stay in the totem zone with their activation reset. The cave is a separate zone where the player can stash one card between turns, swapping it with a card from their hand.

  • In solo_rules.lua, add the End Turn section below the Power State Bridge. Copy the following code above return solo:
-- ─── End Turn (partial) ───────────────────────────────────────────────────────

function solo.end_turn(state)
    -- Move remaining hand cards to discard
    engine.moveAllCards(state.zones.hand, state.zones.discard)

    -- Tableau cards go to discard (totems already in totem zone)
    for _, card in ipairs(state.zones.tableau.cards) do
        card.activated = false
        table.insert(state.zones.discard.cards, card)
    end
    state.zones.tableau.cards = {}
    engine.layoutZone(state.zones.tableau)
    engine.layoutZone(state.zones.discard)

    -- Reset totem activation for next turn (cards stay in zone)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end

    state.strength = 0
    state.turn = state.turn + 1
end

What does this code do?

  • moveAllCards() dumps the entire hand into discard. Any cards the player did not activate are lost for this cycle.
  • Tableau cards are moved to discard individually so their activated flags get reset. Then the tableau is cleared.
  • Totem cards are NOT moved. They stay in state.zones.totem with only their activated flag reset to false, making them available again next turn.
  • This is labeled "partial" because RAGE checking and victory/defeat detection will be added in the next step.
  • Save solo_rules.lua.

Now add the cave exchange function. The cave lets a player stash one card and retrieve it on a future turn.

  • In solo_rules.lua, add the Cave section between the Totem Activation and Power State Bridge sections by copying this code:
-- ─── Cave ─────────────────────────────────────────────────────────────────────

function solo.swap_cave(card_from_hand, state)
    -- Save existing cave card (if any)
    local old_cave = nil
    if #state.zones.cave.cards > 0 then
        old_cave = table.remove(state.zones.cave.cards, 1)
    end

    -- Move selected hand card into cave
    engine.removeCard(state.zones.hand, card_from_hand)
    engine.addCard(state.zones.cave, card_from_hand)

    -- Return old cave card to hand (if there was one)
    if old_cave then
        engine.addCard(state.zones.hand, old_cave)
    end

    engine.layoutZone(state.zones.cave)
    engine.layoutZone(state.zones.hand)
    return old_cave
end

What does this code do?

swap_cave() performs a clean swap. It removes any existing cave card, places the selected hand card into the cave, then returns the old cave card to the player's hand. If the cave was empty, the player just stashes a card with nothing coming back.

  • Save solo_rules.lua.

Finally, wire both of these into main.lua.

  • Find your existing end-turn button handler in main.lua. Replace the existing end-of-turn logic with a call to solo.end_turn(state). After the call, draw a new hand by dealing from the deck up to config.hand_size cards.
  • Add a new state variable state.pending_cave_card = nil in initGame().
  • In love.mousepressed, add a click handler for the cave zone. When the player clicks a hand card while the cave is selected (or vice versa), set state.pending_cave_card to the clicked hand card.
  • When the cave zone itself is clicked and state.pending_cave_card is not nil, call solo.swap_cave(state.pending_cave_card, state) and reset state.pending_cave_card to nil.
  • Save main.lua.
  • Run the game with love . and play a full turn. Click a hand card, then click the Grotte zone on the right side. The card should move to the cave. End your turn and verify totem cards stay in their zone while tableau cards disappear to discard.

Cave swap not working or crash on click?

  • Make sure you check state.pending_cave_card ~= nil before calling solo.swap_cave(). Clicking the cave zone without selecting a hand card first should do nothing.
  • If the turn counter is not incrementing, verify that your end-turn handler calls solo.end_turn(state) and not the old inline cleanup code.
  • If totem cards disappear at end of turn, check that your SUPPORT handler routes totem cards to state.zones.totem and NOT state.zones.tableau. The end_turn() function only discards tableau cards.

Help me debug end-of-turn cleanup or cave exchange in my Dinoblivion solo game.

Double check that your solo_rules.lua has all the sections in the right order.

✔️ Awesome, I've got everything!

Double check you have saved solo_rules.lua and main.lua.

ⓧ I'd like to double check the full code

Here is the complete solo_rules.lua at this point in the project. Compare it against yours and fix any differences.

-- solo_rules.lua
-- Core solo game rules for Dinoblivion: activation, RAGE, scoring.

local engine = require("engine")
local config = require("game_config")
local card_powers = require("card_powers")

local solo = {}

-- ─── Activation Actions ───────────────────────────────────────────────────────

solo.ACTIONS = {
    USE_POWER    = "use_power",
    SUPPORT      = "support_action",
    HUNT_LEFT    = "hunt_left",
    HUNT_RIGHT   = "hunt_right",
    ATTACK_DINO  = "attack_dino",
    FORM_AMI     = "form_ami",
}

function solo.get_available_actions(card, state)
    local actions = {}
    local db_entry = config.get_card(card.id)
    if not db_entry then return actions end

    if db_entry.card_power and db_entry.card_type == "clan" then
        table.insert(actions, { key = solo.ACTIONS.USE_POWER, label = "Pouvoir" })
    end

    local has_action_in_hand = false
    for _, c in ipairs(state.zones.hand.cards) do
        if c.id ~= card.id then
            local cdb = config.get_card(c.id)
            if cdb and cdb.card_type == "action" then
                has_action_in_hand = true
                break
            end
        end
    end
    if has_action_in_hand then
        table.insert(actions, { key = solo.ACTIONS.SUPPORT, label = "Activer Action" })
    end

    if (db_entry.strength or 0) > 0 and #state.zones.jungle.cards > 0 then
        table.insert(actions, { key = solo.ACTIONS.HUNT_LEFT, label = "Chasser Gauche" })
        table.insert(actions, { key = solo.ACTIONS.HUNT_RIGHT, label = "Chasser Droite" })
    end

    if state.strength > 0 or state.resources.dino_tokens.current > 0 then
        table.insert(actions, { key = solo.ACTIONS.ATTACK_DINO, label = "Attaquer Dino" })
    end

    return actions
end

function solo.activate_clan_card(card, state)
    card.activated = true
    engine.removeCard(state.zones.hand, card)
    engine.addCard(state.zones.tableau, card)
    state.strength = state.strength + (card.strength or 0)
end

-- ─── Hunt Logic ───────────────────────────────────────────────────────────────

function solo.hunt(card, territory, state)
    local db_entry = config.get_card(card.id)
    local force = db_entry and db_entry.strength or card.strength or 0
    if force <= 0 then return 0, "Pas de Force pour chasser." end

    local jungle = state.zones.jungle
    local food_gained = 0
    local revealed = 0

    for _ = 1, force do
        if #jungle.cards == 0 then break end
        local c = table.remove(jungle.cards, #jungle.cards)
        c.face_up = true

        local c_db = config.get_card(c.id)
        if c_db and c_db.type == "enemy" then
            c.face_up = false
            table.insert(jungle.cards, 1, c)
        else
            engine.addCard(territory, c)
            food_gained = food_gained + (c_db and c_db.food_reward or 0)
            revealed = revealed + 1
        end
    end

    engine.layoutZone(jungle)
    if food_gained > 0 then
        local res = state.resources.food
        res.current = math.min(res.current + food_gained, res.max)
    end

    return revealed, food_gained
end

-- ─── Totem Activation ─────────────────────────────────────────────────────────

function solo.activate_totems_start_of_turn(state)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end
end

function solo.activate_single_totem(card, state)
    if card.activated then return false, "Deja active ce tour." end
    card.activated = true

    local db_entry = config.get_card(card.id)
    if db_entry and db_entry.card_power then
        local power_fn = card_powers[db_entry.card_power]
        if power_fn then
            local gs = solo.build_power_state(state)
            local ok = power_fn(gs, card)
            solo.apply_power_state(gs, state)
            return ok, gs.message or ""
        end
    end
    return false, "Pas de pouvoir."
end

-- ─── Cave ─────────────────────────────────────────────────────────────────────

function solo.swap_cave(card_from_hand, state)
    -- Save existing cave card (if any)
    local old_cave = nil
    if #state.zones.cave.cards > 0 then
        old_cave = table.remove(state.zones.cave.cards, 1)
    end

    -- Move selected hand card into cave
    engine.removeCard(state.zones.hand, card_from_hand)
    engine.addCard(state.zones.cave, card_from_hand)

    -- Return old cave card to hand (if there was one)
    if old_cave then
        engine.addCard(state.zones.hand, old_cave)
    end

    engine.layoutZone(state.zones.cave)
    engine.layoutZone(state.zones.hand)
    return old_cave
end

-- ─── Power State Bridge ───────────────────────────────────────────────────────
-- Translates between main.lua state and card_powers.lua gs format

function solo.build_power_state(state)
    return {
        food = state.resources.food.current,
        ami = state.resources.ami.current,
        dino_tokens = state.resources.dino_tokens.current,
        attack_force = state.strength,
        message = "",
        pending_draw = 0,
        pending_choice = nil,
        pending_destroy = nil,
        pending_egg_gain = 0,
        extra_actions = 0,
        in_play = {},
        totems = state.zones.totem.cards,
    }
end

function solo.apply_power_state(gs, state)
    state.resources.food.current = math.min(gs.food, state.resources.food.max)
    state.resources.ami.current = math.min(gs.ami, state.resources.ami.max)
    state.resources.dino_tokens.current = math.min(gs.dino_tokens, state.resources.dino_tokens.max)
    state.strength = gs.attack_force
    state.message = gs.message or state.message

    if (gs.pending_draw or 0) > 0 then
        engine.dealCards(state.zones.deck, state.zones.hand, gs.pending_draw)
    end

    if (gs.pending_egg_gain or 0) > 0 then
        solo.gain_egg(state, gs.pending_egg_gain)
    end
end

-- ─── End Turn (partial) ───────────────────────────────────────────────────────

function solo.end_turn(state)
    -- Move remaining hand cards to discard
    engine.moveAllCards(state.zones.hand, state.zones.discard)

    -- Tableau cards go to discard (totems already in totem zone)
    for _, card in ipairs(state.zones.tableau.cards) do
        card.activated = false
        table.insert(state.zones.discard.cards, card)
    end
    state.zones.tableau.cards = {}
    engine.layoutZone(state.zones.tableau)
    engine.layoutZone(state.zones.discard)

    -- Reset totem activation for next turn (cards stay in zone)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end

    state.strength = 0
    state.turn = state.turn + 1
end

return solo

Your game now supports the full action card flow with clan support, persistent totems, and the cave stash. Next up, you will implement the RAGE mechanic that punishes slow play by flooding territories when the deck runs out.

Implement the RAGE Mechanic

Your activation system gives the player control, but Dinoblivion needs a way to push back. Right now, the player can take their time playing cards with no consequence for slow play.

The RAGE mechanic is Dinoblivion's answer. When the player's deck empties at the start of a turn, the jungle erupts and floods both territories with cards based on each dinosaur's level. If the jungle itself runs dry during RAGE, the volcano erupts and the player loses.

In this step, get ready to:
  • Build the RAGE check and trigger functions that reveal jungle cards to territories based on dino level.
  • Add discard recycling, egg bank support, and a complete new-turn flow with defeat detection.
  • Wire up RAGE animation in main.lua with a red border flash and enemy tracking arrays.
Create the RAGE check and trigger functions

RAGE fires at the start of a turn when the player deck is empty. For each dino pile, the game reveals cards from the jungle equal to the dinosaur's level (1 or 2). If a pile is empty, the level defaults to 3 (the volcano penalty).

Enemies add extra danger. If an enemy card is revealed during RAGE, it gets placed on that territory's side and triggers one additional card reveal from the jungle.

  • In solo_rules.lua, find the Hunt Logic section. After the closing end of the solo.hunt() function, add the following RAGE section:
-- ─── RAGE Mechanic ────────────────────────────────────────────────────────────

function solo.check_rage(state)
    return #state.zones.deck.cards == 0
end

function solo.trigger_rage(state)
    local jungle = state.zones.jungle
    local results = { cards_revealed = 0, enemies_placed = 0, defeat = false }

    local sides = {
        { territory = state.zones.territory_left,  pile = state.dino_piles[1],
          enemies = state.enemies_left },
        { territory = state.zones.territory_right, pile = state.dino_piles[2],
          enemies = state.enemies_right },
    }

    for _, side in ipairs(sides) do
        local level = 3 -- default if pile is empty (volcano)
        if #side.pile.zone.cards > 0 then
            level = side.pile.level
        end

        local extra_for_enemies = #side.enemies
        local total_to_reveal = level + extra_for_enemies

        for _ = 1, total_to_reveal do
            if #jungle.cards == 0 then
                results.defeat = true
                return results
            end

            local c = table.remove(jungle.cards, #jungle.cards)
            c.face_up = true

            local c_db = config.get_card(c.id)
            if c_db and c_db.type == "enemy" then
                -- Enemy during RAGE: place on this side, reveal 1 more
                table.insert(side.enemies, c)
                results.enemies_placed = results.enemies_placed + 1

                -- Reveal 1 additional card for this enemy
                if #jungle.cards > 0 then
                    local extra = table.remove(jungle.cards, #jungle.cards)
                    extra.face_up = true
                    engine.addCard(side.territory, extra)
                    results.cards_revealed = results.cards_revealed + 1
                else
                    results.defeat = true
                    return results
                end
            else
                engine.addCard(side.territory, c)
                results.cards_revealed = results.cards_revealed + 1
            end
        end
    end

    engine.layoutZone(jungle)
    return results
end

What does this code do?

check_rage() is a simple boolean check. It returns true when the player deck has zero cards, signaling that RAGE should fire.

trigger_rage() loops over both sides (left territory and right territory). For each side, it calculates how many cards to reveal: the dino pile's level plus one extra card per enemy already on that side.

  • If the dino pile is empty, level defaults to 3 (the volcano penalty for not defeating dinosaurs fast enough).
  • If an enemy is revealed, it gets placed into that side's enemy array and immediately triggers one more card reveal from the jungle.
  • If the jungle empties at any point during reveals, results.defeat is set to true and the function returns early.
  • Save solo_rules.lua.

Seeing a nil value error on enemies?

The trigger function reads state.enemies_left and state.enemies_right. These arrays don't exist yet. We'll initialize them in substep 3 when we update main.lua. Don't run the game until that substep is complete.

Help me debug a nil value error in trigger_rage

Add the discard recycle and egg bank functions

After RAGE resolves, the discard pile gets recycled back into the deck so the player can keep drawing. The egg bank also needs functions so card powers that award eggs can work correctly.

  • In solo_rules.lua, add the recycle_discard function directly below the trigger_rage function you just added:
function solo.recycle_discard(state)
    engine.moveAllCards(state.zones.discard, state.zones.deck)
    for _, c in ipairs(state.zones.deck.cards) do
        c.face_up = false
        c.activated = false
    end
    engine.shuffle(state.zones.deck)
end

What does this code do?

This moves every card from the discard zone into the deck, flips them all face-down, clears any activation state, and shuffles. The player gets a fresh deck without gaining or losing any cards.

  • Save solo_rules.lua.

Next, the egg bank. The apply_power_state bridge already calls solo.gain_egg() when a card power awards eggs. We need the actual functions backing that call.

  • Find the Totem Activation section heading in solo_rules.lua. Add the following egg bank section directly above it (between recycle_discard and the totem section):
-- ─── Egg Bank ─────────────────────────────────────────────────────────────────

function solo.init_egg_bank(state)
    state.egg_bank = {}
    -- Build pool: 12 vert (1pt), 8 rouge (2pt), 4 bleu (2pt indestructible)
    local pool = {}
    for _ = 1, 12 do table.insert(pool, { value = 1, color = "vert", indestructible = false }) end
    for _ = 1, 8 do table.insert(pool, { value = 2, color = "rouge", indestructible = false }) end
    for _ = 1, 4 do table.insert(pool, { value = 2, color = "bleu", indestructible = true }) end

    -- Shuffle
    for i = #pool, 2, -1 do
        local j = love.math.random(i)
        pool[i], pool[j] = pool[j], pool[i]
    end

    -- Take 12 for solo
    for i = 1, math.min(12, #pool) do
        table.insert(state.egg_bank, pool[i])
    end

    state.eggs_collected = {}
end

What does this code do?

The egg pool has 24 total eggs across three colors: 12 green (1 VP each), 8 red (2 VP each), and 4 blue (2 VP, indestructible). The function shuffles the full pool and takes the first 12 for solo mode.

  • Directly below init_egg_bank, add the gain_egg function:
function solo.gain_egg(state, count)
    count = count or 1
    for _ = 1, count do
        if #state.egg_bank == 0 then break end
        local egg = table.remove(state.egg_bank, 1)
        egg.revealed = true
        table.insert(state.eggs_collected, egg)

        -- Solo mechanic: egg value = cards to add under jungle from reserve
        if state.jungle_reserve then
            for _ = 1, egg.value do
                if #state.jungle_reserve > 0 then
                    local card_id = table.remove(state.jungle_reserve, 1)
                    local db_entry = config.get_card(card_id)
                    if db_entry then
                        local card = engine.newCard(card_id, config.make_card(db_entry))
                        card.face_up = false
                        table.insert(state.zones.jungle.cards, 1, card)
                    end
                end
            end
            engine.layoutZone(state.zones.jungle)
        end
    end
end

Why do eggs add cards to the jungle?

In solo Dinoblivion, gaining an egg extends the game. Each egg's point value determines how many cards from the jungle reserve get inserted at the bottom of the jungle pile. This delays the volcano eruption and gives the player more time.

  • Save solo_rules.lua.

Unsure where each section goes?

The section order in solo_rules.lua should be: Activation Actions, Hunt Logic, RAGE Mechanic, Egg Bank, Totem Activation, Cave, Power State Bridge, End Turn. Check the full code tab at the end of this step to confirm placement.

Help me organize the sections in solo_rules.lua

Now for the big piece. The existing end_turn function needs a victory check, and we need a brand new start_new_turn function that checks for RAGE before drawing a new hand.

  • Scroll to the bottom of solo_rules.lua and find the End Turn section.
  • Replace the entire solo.end_turn function (and everything below it until return solo) with the following updated version:
-- ─── End Turn ─────────────────────────────────────────────────────────────────

function solo.end_turn(state)
    -- Move hand to discard
    engine.moveAllCards(state.zones.hand, state.zones.discard)

    -- Tableau: all cards go to discard (totems already in totem zone)
    for _, card in ipairs(state.zones.tableau.cards) do
        card.activated = false
        table.insert(state.zones.discard.cards, card)
    end
    state.zones.tableau.cards = {}
    engine.layoutZone(state.zones.tableau)
    engine.layoutZone(state.zones.discard)

    -- Reset totem activation for next turn
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end

    state.strength = 0
    state.turn = state.turn + 1

    -- Check victory: all dino piles empty
    local all_clear = true
    for _, p in ipairs(state.dino_piles) do
        if #p.zone.cards > 0 then all_clear = false; break end
    end

    if all_clear then
        state.game_over = true
        state.victory = true
        state.message = "Victoire !"
        return
    end

    solo.start_new_turn(state)
end

function solo.start_new_turn(state)
    -- Check RAGE before drawing
    if solo.check_rage(state) then
        state.rage_active = true
        local results = solo.trigger_rage(state)

        if results.defeat then
            state.game_over = true
            state.victory = false
            state.message = "Defaite ! Le volcan entre en eruption !"
            return
        end

        state.message = "RAGE ! " .. results.cards_revealed .. " carte(s) revelees"
        if results.enemies_placed > 0 then
            state.message = state.message .. ", " .. results.enemies_placed .. " ennemi(s) !"
        end

        -- Recycle discard into deck
        solo.recycle_discard(state)
        state.rage_active = false
    end

    -- Draw up to 4
    local hand = state.zones.hand
    local deck = state.zones.deck
    local needed = config.hand_size - #hand.cards
    if needed > 0 then
        if #deck.cards < needed then
            solo.recycle_discard(state)
        end
        engine.dealCards(deck, hand, needed)
    end

    -- Activate totems
    solo.activate_totems_start_of_turn(state)

    if not state.rage_active then
        state.message = "Tour " .. state.turn .. " — Cliquez une carte Clan pour agir"
    end
end

return solo

What does this code do?

  • end_turn() now checks for victory after cleanup. If all dino piles are empty, the player wins and the game ends immediately.
  • start_new_turn() is the new turn sequence. It checks RAGE first, triggers it if needed, then recycles the discard into the deck, draws up to 4 cards, and reactivates totems.
  • If the jungle empties during RAGE, results.defeat fires and the function sets state.game_over = true with state.victory = false.
  • The safety net for drawing also calls recycle_discard() if the deck doesn't have enough cards to fill the hand, preventing a crash when drawing from an empty deck.
  • Save solo_rules.lua.

Getting a stack overflow in start_new_turn?

If recycle_discard is called but the discard is also empty, the deck stays at 0 cards. This can cause an infinite RAGE loop. Make sure your check_rage only runs once per turn by confirming state.rage_active is set to false after recycling.

Help me debug a stack overflow in start_new_turn

Wire up RAGE animation and enemy tracking in main.lua

The solo rules module handles the logic, but the player needs visual feedback when RAGE fires. A red border flash and "RAGE!" text overlay for 1 second makes the event unmissable.

  • In main.lua, find your initGame() function.
  • Add the following lines where you initialize the other state variables (near state.strength and state.turn):
-- Enemy tracking for RAGE (one array per territory side)
state.enemies_left = {}
state.enemies_right = {}

-- RAGE animation state
state.rage_active = false
state.rage_timer = 0

What does this code do?

enemies_left and enemies_right are the arrays that trigger_rage() reads and writes to. Each enemy revealed during RAGE gets placed into the matching side's array. rage_timer controls the 1-second visual overlay.

  • Save main.lua.

Next, the RAGE animation timer needs to count down each frame so the red border disappears after 1 second.

  • Find your love.update(dt) function in main.lua.
  • Add the following rage timer countdown inside the playing state block:
-- Decrement RAGE animation timer
if state.rage_timer > 0 then
    state.rage_timer = state.rage_timer - dt
end

What does this code do?

Each frame, dt (delta time in seconds) is subtracted from the timer. When rage_timer reaches 0, the RAGE overlay stops rendering.

  • Save main.lua.

Finally, the red border and "RAGE!" text need to render when the timer is active.

  • Find your drawGame() function in main.lua.
  • Add the following RAGE overlay at the end of the function, just before the final end:
-- RAGE visual overlay: red border flash + text
if state.rage_timer > 0 then
    love.graphics.setColor(1, 0, 0, 0.6)
    love.graphics.setLineWidth(6)
    love.graphics.rectangle("line", 3, 3, 1274, 794)
    love.graphics.setLineWidth(1)

    love.graphics.setColor(1, 0, 0, 1)
    love.graphics.printf("RAGE!", 0, 350, 1280, "center")
    love.graphics.setColor(1, 1, 1, 1)
end

What does this code do?

  • A 6-pixel red border is drawn around the entire 1280x800 virtual canvas with 60% opacity.
  • "RAGE!" is drawn in solid red, centered on screen at y=350.
  • The color is reset to white at the end so subsequent draws aren't tinted red.

The last wiring step is setting rage_timer to 1.0 when RAGE fires.

  • Find where your end-turn button handler calls solo.end_turn(state) in main.lua.
  • Add the following check immediately after the solo.end_turn(state) call:
-- Trigger RAGE animation if RAGE just fired
if state.rage_active or (state.message and state.message:find("RAGE")) then
    state.rage_timer = 1.0
end

What does this code do?

After end_turn runs, this checks whether RAGE was triggered by looking at state.rage_active or the status message. If RAGE fired, the animation timer starts at 1 second and counts down in love.update(dt).

  • Save main.lua.
  • Run the game by running:
love .
  • Play through several turns until your deck empties. You should see a red border flash around the screen with "RAGE!" text for about 1 second. Cards from the jungle should appear on the territory zones.

RAGE never triggers?

Check that your end_turn() function calls solo.start_new_turn(state) at the bottom. If it's missing, the RAGE check never runs.

Also confirm that your hand cards are being moved to discard (not deleted) during end_turn. If the deck stays non-empty because cards are vanishing, RAGE will never fire.

Help me figure out why RAGE never triggers

✔️ Awesome, I've got everything!

Double check you've saved both solo_rules.lua and main.lua.

ⓧ I'd like to double check the full code

Here is the complete solo_rules.lua as it should look at the end of this step:

-- solo_rules.lua
-- Core solo game rules for Dinoblivion: activation, RAGE, scoring.

local engine = require("engine")
local config = require("game_config")
local card_powers = require("card_powers")

local solo = {}

-- ─── Activation Actions ───────────────────────────────────────────────────────

solo.ACTIONS = {
    USE_POWER    = "use_power",
    SUPPORT      = "support_action",
    HUNT_LEFT    = "hunt_left",
    HUNT_RIGHT   = "hunt_right",
    ATTACK_DINO  = "attack_dino",
    FORM_AMI     = "form_ami",
}

function solo.get_available_actions(card, state)
    local actions = {}
    local db_entry = config.get_card(card.id)
    if not db_entry then return actions end

    if db_entry.card_power and db_entry.card_type == "clan" then
        table.insert(actions, { key = solo.ACTIONS.USE_POWER, label = "Pouvoir" })
    end

    local has_action_in_hand = false
    for _, c in ipairs(state.zones.hand.cards) do
        if c.id ~= card.id then
            local cdb = config.get_card(c.id)
            if cdb and cdb.card_type == "action" then
                has_action_in_hand = true
                break
            end
        end
    end
    if has_action_in_hand then
        table.insert(actions, { key = solo.ACTIONS.SUPPORT, label = "Activer Action" })
    end

    if (db_entry.strength or 0) > 0 and #state.zones.jungle.cards > 0 then
        table.insert(actions, { key = solo.ACTIONS.HUNT_LEFT, label = "Chasser Gauche" })
        table.insert(actions, { key = solo.ACTIONS.HUNT_RIGHT, label = "Chasser Droite" })
    end

    if state.strength > 0 or state.resources.dino_tokens.current > 0 then
        table.insert(actions, { key = solo.ACTIONS.ATTACK_DINO, label = "Attaquer Dino" })
    end

    return actions
end

function solo.activate_clan_card(card, state)
    card.activated = true
    engine.removeCard(state.zones.hand, card)
    engine.addCard(state.zones.tableau, card)
    state.strength = state.strength + (card.strength or 0)
end

-- ─── Hunt Logic ───────────────────────────────────────────────────────────────

function solo.hunt(card, territory, state)
    local db_entry = config.get_card(card.id)
    local force = db_entry and db_entry.strength or card.strength or 0
    if force <= 0 then return 0, "Pas de Force pour chasser." end

    local jungle = state.zones.jungle
    local food_gained = 0
    local revealed = 0

    for _ = 1, force do
        if #jungle.cards == 0 then break end
        local c = table.remove(jungle.cards, #jungle.cards)
        c.face_up = true

        local c_db = config.get_card(c.id)
        if c_db and c_db.type == "enemy" then
            c.face_up = false
            table.insert(jungle.cards, 1, c)
        else
            engine.addCard(territory, c)
            food_gained = food_gained + (c_db and c_db.food_reward or 0)
            revealed = revealed + 1
        end
    end

    engine.layoutZone(jungle)
    if food_gained > 0 then
        local res = state.resources.food
        res.current = math.min(res.current + food_gained, res.max)
    end

    return revealed, food_gained
end

-- ─── RAGE Mechanic ────────────────────────────────────────────────────────────

function solo.check_rage(state)
    return #state.zones.deck.cards == 0
end

function solo.trigger_rage(state)
    local jungle = state.zones.jungle
    local results = { cards_revealed = 0, enemies_placed = 0, defeat = false }

    local sides = {
        { territory = state.zones.territory_left,  pile = state.dino_piles[1],
          enemies = state.enemies_left },
        { territory = state.zones.territory_right, pile = state.dino_piles[2],
          enemies = state.enemies_right },
    }

    for _, side in ipairs(sides) do
        local level = 3
        if #side.pile.zone.cards > 0 then
            level = side.pile.level
        end

        local extra_for_enemies = #side.enemies
        local total_to_reveal = level + extra_for_enemies

        for _ = 1, total_to_reveal do
            if #jungle.cards == 0 then
                results.defeat = true
                return results
            end

            local c = table.remove(jungle.cards, #jungle.cards)
            c.face_up = true

            local c_db = config.get_card(c.id)
            if c_db and c_db.type == "enemy" then
                table.insert(side.enemies, c)
                results.enemies_placed = results.enemies_placed + 1

                if #jungle.cards > 0 then
                    local extra = table.remove(jungle.cards, #jungle.cards)
                    extra.face_up = true
                    engine.addCard(side.territory, extra)
                    results.cards_revealed = results.cards_revealed + 1
                else
                    results.defeat = true
                    return results
                end
            else
                engine.addCard(side.territory, c)
                results.cards_revealed = results.cards_revealed + 1
            end
        end
    end

    engine.layoutZone(jungle)
    return results
end

function solo.recycle_discard(state)
    engine.moveAllCards(state.zones.discard, state.zones.deck)
    for _, c in ipairs(state.zones.deck.cards) do
        c.face_up = false
        c.activated = false
    end
    engine.shuffle(state.zones.deck)
end

-- ─── Egg Bank ─────────────────────────────────────────────────────────────────

function solo.init_egg_bank(state)
    state.egg_bank = {}
    -- Build pool: 12 vert (1pt), 8 rouge (2pt), 4 bleu (2pt indestructible)
    local pool = {}
    for _ = 1, 12 do table.insert(pool, { value = 1, color = "vert", indestructible = false }) end
    for _ = 1, 8 do table.insert(pool, { value = 2, color = "rouge", indestructible = false }) end
    for _ = 1, 4 do table.insert(pool, { value = 2, color = "bleu", indestructible = true }) end

    -- Shuffle
    for i = #pool, 2, -1 do
        local j = love.math.random(i)
        pool[i], pool[j] = pool[j], pool[i]
    end

    -- Take 12 for solo
    for i = 1, math.min(12, #pool) do
        table.insert(state.egg_bank, pool[i])
    end

    state.eggs_collected = {}
end

function solo.gain_egg(state, count)
    count = count or 1
    for _ = 1, count do
        if #state.egg_bank == 0 then break end
        local egg = table.remove(state.egg_bank, 1)
        egg.revealed = true
        table.insert(state.eggs_collected, egg)

        -- Solo mechanic: egg value = cards to add under jungle from reserve
        if state.jungle_reserve then
            for _ = 1, egg.value do
                if #state.jungle_reserve > 0 then
                    local card_id = table.remove(state.jungle_reserve, 1)
                    local db_entry = config.get_card(card_id)
                    if db_entry then
                        local card = engine.newCard(card_id, config.make_card(db_entry))
                        card.face_up = false
                        table.insert(state.zones.jungle.cards, 1, card)
                    end
                end
            end
            engine.layoutZone(state.zones.jungle)
        end
    end
end

-- ─── Totem Activation ─────────────────────────────────────────────────────────

function solo.activate_totems_start_of_turn(state)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end
end

function solo.activate_single_totem(card, state)
    if card.activated then return false, "Deja active ce tour." end
    card.activated = true

    local db_entry = config.get_card(card.id)
    if db_entry and db_entry.card_power then
        local power_fn = card_powers[db_entry.card_power]
        if power_fn then
            local gs = solo.build_power_state(state)
            local ok = power_fn(gs, card)
            solo.apply_power_state(gs, state)
            return ok, gs.message or ""
        end
    end
    return false, "Pas de pouvoir."
end

-- ─── Cave ─────────────────────────────────────────────────────────────────────

function solo.swap_cave(card_from_hand, state)
    local old_cave = nil
    if #state.zones.cave.cards > 0 then
        old_cave = table.remove(state.zones.cave.cards, 1)
    end

    engine.removeCard(state.zones.hand, card_from_hand)
    engine.addCard(state.zones.cave, card_from_hand)

    if old_cave then
        engine.addCard(state.zones.hand, old_cave)
    end

    engine.layoutZone(state.zones.cave)
    engine.layoutZone(state.zones.hand)
    return old_cave
end

-- ─── Power State Bridge ───────────────────────────────────────────────────────

function solo.build_power_state(state)
    return {
        food = state.resources.food.current,
        ami = state.resources.ami.current,
        dino_tokens = state.resources.dino_tokens.current,
        attack_force = state.strength,
        message = "",
        pending_draw = 0,
        pending_choice = nil,
        pending_destroy = nil,
        pending_egg_gain = 0,
        extra_actions = 0,
        in_play = {},
        totems = state.zones.totem.cards,
    }
end

function solo.apply_power_state(gs, state)
    state.resources.food.current = math.min(gs.food, state.resources.food.max)
    state.resources.ami.current = math.min(gs.ami, state.resources.ami.max)
    state.resources.dino_tokens.current = math.min(gs.dino_tokens, state.resources.dino_tokens.max)
    state.strength = gs.attack_force
    state.message = gs.message or state.message

    if (gs.pending_draw or 0) > 0 then
        engine.dealCards(state.zones.deck, state.zones.hand, gs.pending_draw)
    end

    if (gs.pending_egg_gain or 0) > 0 then
        solo.gain_egg(state, gs.pending_egg_gain)
    end
end

-- ─── End Turn ─────────────────────────────────────────────────────────────────

function solo.end_turn(state)
    -- Move hand to discard
    engine.moveAllCards(state.zones.hand, state.zones.discard)

    -- Tableau: all cards go to discard (totems already in totem zone)
    for _, card in ipairs(state.zones.tableau.cards) do
        card.activated = false
        table.insert(state.zones.discard.cards, card)
    end
    state.zones.tableau.cards = {}
    engine.layoutZone(state.zones.tableau)
    engine.layoutZone(state.zones.discard)

    -- Reset totem activation for next turn
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end

    state.strength = 0
    state.turn = state.turn + 1

    -- Check victory: all dino piles empty
    local all_clear = true
    for _, p in ipairs(state.dino_piles) do
        if #p.zone.cards > 0 then all_clear = false; break end
    end

    if all_clear then
        state.game_over = true
        state.victory = true
        state.message = "Victoire !"
        return
    end

    solo.start_new_turn(state)
end

function solo.start_new_turn(state)
    -- Check RAGE before drawing
    if solo.check_rage(state) then
        state.rage_active = true
        local results = solo.trigger_rage(state)

        if results.defeat then
            state.game_over = true
            state.victory = false
            state.message = "Defaite ! Le volcan entre en eruption !"
            return
        end

        state.message = "RAGE ! " .. results.cards_revealed .. " carte(s) revelees"
        if results.enemies_placed > 0 then
            state.message = state.message .. ", " .. results.enemies_placed .. " ennemi(s) !"
        end

        -- Recycle discard into deck
        solo.recycle_discard(state)
        state.rage_active = false
    end

    -- Draw up to 4
    local hand = state.zones.hand
    local deck = state.zones.deck
    local needed = config.hand_size - #hand.cards
    if needed > 0 then
        if #deck.cards < needed then
            solo.recycle_discard(state)
        end
        engine.dealCards(deck, hand, needed)
    end

    -- Activate totems
    solo.activate_totems_start_of_turn(state)

    if not state.rage_active then
        state.message = "Tour " .. state.turn .. " — Cliquez une carte Clan pour agir"
    end
end

return solo

Your game now punishes slow play. When the deck empties, RAGE floods both territories with jungle cards. Next up, you'll build the end-of-game scoring system that tallies families, totems, dinosaurs, and eggs into a final victory point total.

End-of-Game Scoring

Your game now has a complete turn loop with card activation, RAGE, caves, totems, and eggs. Players can hunt, attack dinosaurs, and trigger devastating RAGE events.

But there is no payoff. When the game ends, all the learner sees is a text message. In this step, you will build the full Dinoblivion scoring algorithm that tallies four point sources (families, totems, dinosaurs, and eggs) and display a detailed breakdown screen.

In this step, get ready to:
  • Create calculate_final_score() with ami family matching, totem counting, dino trophies, and egg values.
  • Wire scoring into both the victory and defeat code paths.
  • Build a score breakdown overlay screen with restart and menu options.
Create the scoring function

Dinoblivion scores victory points from four sources. Ami families (matched left/right pairs) are worth 2 VP each. Totem symbols in the totem zone are worth 1 VP each. Each defeated dinosaur in the trophies zone is worth 1 VP. Collected eggs contribute their face value (1 or 2 points).

  • In solo_rules.lua, find the comment line that says -- ─── Power State Bridge.
  • Add the following scoring function above that line:
-- ─── End-of-Game Scoring ──────────────────────────────────────────────────────

function solo.calculate_final_score(state)
    local score = { families = 0, totems = 0, dinos = 0, eggs = 0, total = 0 }

    -- 1. Collect all player cards from every zone
    local all_cards = {}
    local zones_to_check = { state.zones.deck, state.zones.discard, state.zones.hand,
                             state.zones.tableau, state.zones.totem, state.zones.cave }
    for _, zone in ipairs(zones_to_check) do
        for _, card in ipairs(zone.cards) do
            table.insert(all_cards, card)
        end
    end

    -- 2. Count ami families (pairs)
    local lefts = {}
    local rights = {}
    for _, card in ipairs(all_cards) do
        local db = config.get_card(card.id)
        if db then
            local side = db.ami_side
            if side == "left" or side == "both" then
                table.insert(lefts, card)
            end
            if side == "right" or side == "both" then
                table.insert(rights, card)
            end
        end
    end

    -- Match pairs (greedy): each left pairs with one right (no self-pairing for both)
    local used_rights = {}
    for _, l in ipairs(lefts) do
        for ri, r in ipairs(rights) do
            if not used_rights[ri] and l ~= r then
                used_rights[ri] = true
                score.families = score.families + 1
                break
            end
        end
    end

    -- 3. Count totem symbols (totem zone only)
    for _, card in ipairs(state.zones.totem.cards) do
        local db = config.get_card(card.id)
        if db and db.has_totem then
            score.totems = score.totems + (db.totem_count or 1)
        end
    end

    -- 4. Count defeated dinosaurs
    score.dinos = #state.zones.trophies.cards

    -- 5. Sum egg values
    for _, egg in ipairs(state.eggs_collected or {}) do
        score.eggs = score.eggs + egg.value
    end

    -- Total
    score.total = (score.families * 2) + (score.totems * 1) + (score.dinos * 1) + score.eggs

    return score
end

How does family matching work?

Each card in the database has an ami_side property: "left", "right", or "both". A family is formed when a left card pairs with a right card.

Cards with "both" appear in both the lefts and rights lists, so they can match with either side. The l ~= r check prevents a "both" card from pairing with itself.

The greedy algorithm matches each left card to the first available right card. This is simple and works correctly because all pairs are worth the same 2 VP.

Why only totem zone cards for totem scoring?

Totems only count if they are still in the totem zone at game end. Cards that were discarded or remain in your deck do not contribute totem VP, even if they have the has_totem flag. This rewards players who actively placed action cards into the totem zone during the game.

  • Save solo_rules.lua.

You won't see a change yet. The next substep wires this function into the game-over triggers.

Wire scoring into victory and defeat

Right now, end_turn() sets state.game_over = true on victory but never calculates the score. The defeat path in start_new_turn() has the same gap. Both need to call your new scoring function.

  • In solo_rules.lua, scroll down to the solo.end_turn() function.
  • Find this block inside the if all_clear then check:
    if all_clear then
        state.game_over = true
        state.victory = true
        state.message = "Victoire !"
        return
    end
  • Replace that block with the following, which calculates the score and includes it in the victory message:
    if all_clear then
        state.game_over = true
        state.victory = true
        state.final_score = solo.calculate_final_score(state)
        state.message = "Victoire ! Score : " .. state.final_score.total
        return
    end
  • Now scroll down to solo.start_new_turn().
  • Find the defeat block inside the RAGE check:
        if results.defeat then
            state.game_over = true
            state.victory = false
            state.message = "Defaite ! Le volcan entre en eruption !"
            return
        end
  • Replace it with this version that also calculates the final score on defeat:
        if results.defeat then
            state.game_over = true
            state.victory = false
            state.final_score = solo.calculate_final_score(state)
            state.message = "Defaite ! Le volcan entre en eruption !"
            return
        end
  • Save solo_rules.lua.

Both game-over paths now populate state.final_score before the game-over screen triggers. The score screen you build next will read this table.

✔️ Awesome, I've got everything!

Great. Double check you have saved solo_rules.lua.

ⓧ I'd like to double check the full code

Here is the full solo_rules.lua at this point. Compare your file against it.

-- solo_rules.lua
-- Core solo game rules for Dinoblivion: activation, RAGE, scoring.

local engine = require("engine")
local config = require("game_config")
local card_powers = require("card_powers")

local solo = {}

-- ─── Activation Actions ───────────────────────────────────────────────────────

solo.ACTIONS = {
    USE_POWER    = "use_power",
    SUPPORT      = "support_action",
    HUNT_LEFT    = "hunt_left",
    HUNT_RIGHT   = "hunt_right",
    ATTACK_DINO  = "attack_dino",
    FORM_AMI     = "form_ami",
}

function solo.get_available_actions(card, state)
    local actions = {}
    local db_entry = config.get_card(card.id)
    if not db_entry then return actions end

    if db_entry.card_power and db_entry.card_type == "clan" then
        table.insert(actions, { key = solo.ACTIONS.USE_POWER, label = "Pouvoir" })
    end

    local has_action_in_hand = false
    for _, c in ipairs(state.zones.hand.cards) do
        if c.id ~= card.id then
            local cdb = config.get_card(c.id)
            if cdb and cdb.card_type == "action" then
                has_action_in_hand = true
                break
            end
        end
    end
    if has_action_in_hand then
        table.insert(actions, { key = solo.ACTIONS.SUPPORT, label = "Activer Action" })
    end

    if (db_entry.strength or 0) > 0 and #state.zones.jungle.cards > 0 then
        table.insert(actions, { key = solo.ACTIONS.HUNT_LEFT, label = "Chasser Gauche" })
        table.insert(actions, { key = solo.ACTIONS.HUNT_RIGHT, label = "Chasser Droite" })
    end

    if state.strength > 0 or state.resources.dino_tokens.current > 0 then
        table.insert(actions, { key = solo.ACTIONS.ATTACK_DINO, label = "Attaquer Dino" })
    end

    return actions
end

function solo.activate_clan_card(card, state)
    card.activated = true
    engine.removeCard(state.zones.hand, card)
    engine.addCard(state.zones.tableau, card)
    state.strength = state.strength + (card.strength or 0)
end

-- ─── Hunt Logic ───────────────────────────────────────────────────────────────

function solo.hunt(card, territory, state)
    local db_entry = config.get_card(card.id)
    local force = db_entry and db_entry.strength or card.strength or 0
    if force <= 0 then return 0, "Pas de Force pour chasser." end

    local jungle = state.zones.jungle
    local food_gained = 0
    local revealed = 0

    for _ = 1, force do
        if #jungle.cards == 0 then break end
        local c = table.remove(jungle.cards, #jungle.cards)
        c.face_up = true

        local c_db = config.get_card(c.id)
        if c_db and c_db.type == "enemy" then
            c.face_up = false
            table.insert(jungle.cards, 1, c)
        else
            engine.addCard(territory, c)
            food_gained = food_gained + (c_db and c_db.food_reward or 0)
            revealed = revealed + 1
        end
    end

    engine.layoutZone(jungle)
    if food_gained > 0 then
        local res = state.resources.food
        res.current = math.min(res.current + food_gained, res.max)
    end

    return revealed, food_gained
end

-- ─── RAGE Mechanic ────────────────────────────────────────────────────────────

function solo.check_rage(state)
    return #state.zones.deck.cards == 0
end

function solo.trigger_rage(state)
    local jungle = state.zones.jungle
    local results = { cards_revealed = 0, enemies_placed = 0, defeat = false }

    local sides = {
        { territory = state.zones.territory_left,  pile = state.dino_piles[1],
          enemies = state.enemies_left },
        { territory = state.zones.territory_right, pile = state.dino_piles[2],
          enemies = state.enemies_right },
    }

    for _, side in ipairs(sides) do
        local level = 3
        if #side.pile.zone.cards > 0 then
            level = side.pile.level
        end

        local extra_for_enemies = #side.enemies
        local total_to_reveal = level + extra_for_enemies

        for _ = 1, total_to_reveal do
            if #jungle.cards == 0 then
                results.defeat = true
                return results
            end

            local c = table.remove(jungle.cards, #jungle.cards)
            c.face_up = true

            local c_db = config.get_card(c.id)
            if c_db and c_db.type == "enemy" then
                table.insert(side.enemies, c)
                results.enemies_placed = results.enemies_placed + 1

                if #jungle.cards > 0 then
                    local extra = table.remove(jungle.cards, #jungle.cards)
                    extra.face_up = true
                    engine.addCard(side.territory, extra)
                    results.cards_revealed = results.cards_revealed + 1
                else
                    results.defeat = true
                    return results
                end
            else
                engine.addCard(side.territory, c)
                results.cards_revealed = results.cards_revealed + 1
            end
        end
    end

    engine.layoutZone(jungle)
    return results
end

function solo.recycle_discard(state)
    engine.moveAllCards(state.zones.discard, state.zones.deck)
    for _, c in ipairs(state.zones.deck.cards) do
        c.face_up = false
        c.activated = false
    end
    engine.shuffle(state.zones.deck)
end

-- ─── Totem Activation ─────────────────────────────────────────────────────────

function solo.activate_totems_start_of_turn(state)
    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end
end

function solo.activate_single_totem(card, state)
    if card.activated then return false, "Deja active ce tour." end
    card.activated = true

    local db_entry = config.get_card(card.id)
    if db_entry and db_entry.card_power then
        local power_fn = card_powers[db_entry.card_power]
        if power_fn then
            local gs = solo.build_power_state(state)
            local ok = power_fn(gs, card)
            solo.apply_power_state(gs, state)
            return ok, gs.message or ""
        end
    end
    return false, "Pas de pouvoir."
end

-- ─── Egg Bank ─────────────────────────────────────────────────────────────────

function solo.init_egg_bank(state)
    state.egg_bank = {}
    local pool = {}
    for _ = 1, 12 do table.insert(pool, { value = 1, color = "vert", indestructible = false }) end
    for _ = 1, 8 do table.insert(pool, { value = 2, color = "rouge", indestructible = false }) end
    for _ = 1, 4 do table.insert(pool, { value = 2, color = "bleu", indestructible = true }) end

    for i = #pool, 2, -1 do
        local j = love.math.random(i)
        pool[i], pool[j] = pool[j], pool[i]
    end

    for i = 1, math.min(12, #pool) do
        table.insert(state.egg_bank, pool[i])
    end

    state.eggs_collected = {}
end

function solo.gain_egg(state, count)
    count = count or 1
    for _ = 1, count do
        if #state.egg_bank == 0 then break end
        local egg = table.remove(state.egg_bank, 1)
        egg.revealed = true
        table.insert(state.eggs_collected, egg)

        if state.jungle_reserve then
            for _ = 1, egg.value do
                if #state.jungle_reserve > 0 then
                    local card_id = table.remove(state.jungle_reserve, 1)
                    local db_entry = config.get_card(card_id)
                    if db_entry then
                        local card = engine.newCard(card_id, config.make_card(db_entry))
                        card.face_up = false
                        table.insert(state.zones.jungle.cards, 1, card)
                    end
                end
            end
            engine.layoutZone(state.zones.jungle)
        end
    end
end

-- ─── Cave ─────────────────────────────────────────────────────────────────────

function solo.swap_cave(card_from_hand, state)
    local old_cave = nil
    if #state.zones.cave.cards > 0 then
        old_cave = table.remove(state.zones.cave.cards, 1)
    end

    engine.removeCard(state.zones.hand, card_from_hand)
    engine.addCard(state.zones.cave, card_from_hand)

    if old_cave then
        engine.addCard(state.zones.hand, old_cave)
    end

    engine.layoutZone(state.zones.cave)
    engine.layoutZone(state.zones.hand)
    return old_cave
end

-- ─── End-of-Game Scoring ──────────────────────────────────────────────────────

function solo.calculate_final_score(state)
    local score = { families = 0, totems = 0, dinos = 0, eggs = 0, total = 0 }

    local all_cards = {}
    local zones_to_check = { state.zones.deck, state.zones.discard, state.zones.hand,
                             state.zones.tableau, state.zones.totem, state.zones.cave }
    for _, zone in ipairs(zones_to_check) do
        for _, card in ipairs(zone.cards) do
            table.insert(all_cards, card)
        end
    end

    local lefts = {}
    local rights = {}
    for _, card in ipairs(all_cards) do
        local db = config.get_card(card.id)
        if db then
            local side = db.ami_side
            if side == "left" or side == "both" then
                table.insert(lefts, card)
            end
            if side == "right" or side == "both" then
                table.insert(rights, card)
            end
        end
    end

    local used_rights = {}
    for _, l in ipairs(lefts) do
        for ri, r in ipairs(rights) do
            if not used_rights[ri] and l ~= r then
                used_rights[ri] = true
                score.families = score.families + 1
                break
            end
        end
    end

    for _, card in ipairs(state.zones.totem.cards) do
        local db = config.get_card(card.id)
        if db and db.has_totem then
            score.totems = score.totems + (db.totem_count or 1)
        end
    end

    score.dinos = #state.zones.trophies.cards

    for _, egg in ipairs(state.eggs_collected or {}) do
        score.eggs = score.eggs + egg.value
    end

    score.total = (score.families * 2) + (score.totems * 1) + (score.dinos * 1) + score.eggs

    return score
end

-- ─── Power State Bridge ───────────────────────────────────────────────────────

function solo.build_power_state(state)
    return {
        food = state.resources.food.current,
        ami = state.resources.ami.current,
        dino_tokens = state.resources.dino_tokens.current,
        attack_force = state.strength,
        message = "",
        pending_draw = 0,
        pending_choice = nil,
        pending_destroy = nil,
        pending_egg_gain = 0,
        extra_actions = 0,
        in_play = {},
        totems = state.zones.totem.cards,
    }
end

function solo.apply_power_state(gs, state)
    state.resources.food.current = math.min(gs.food, state.resources.food.max)
    state.resources.ami.current = math.min(gs.ami, state.resources.ami.max)
    state.resources.dino_tokens.current = math.min(gs.dino_tokens, state.resources.dino_tokens.max)
    state.strength = gs.attack_force
    state.message = gs.message or state.message

    if (gs.pending_draw or 0) > 0 then
        engine.dealCards(state.zones.deck, state.zones.hand, gs.pending_draw)
    end

    if (gs.pending_egg_gain or 0) > 0 then
        solo.gain_egg(state, gs.pending_egg_gain)
    end
end

-- ─── End Turn ─────────────────────────────────────────────────────────────────

function solo.end_turn(state)
    engine.moveAllCards(state.zones.hand, state.zones.discard)

    for _, card in ipairs(state.zones.tableau.cards) do
        card.activated = false
        table.insert(state.zones.discard.cards, card)
    end
    state.zones.tableau.cards = {}
    engine.layoutZone(state.zones.tableau)
    engine.layoutZone(state.zones.discard)

    for _, card in ipairs(state.zones.totem.cards) do
        card.activated = false
    end

    state.strength = 0
    state.turn = state.turn + 1

    local all_clear = true
    for _, p in ipairs(state.dino_piles) do
        if #p.zone.cards > 0 then all_clear = false; break end
    end

    if all_clear then
        state.game_over = true
        state.victory = true
        state.final_score = solo.calculate_final_score(state)
        state.message = "Victoire ! Score : " .. state.final_score.total
        return
    end

    solo.start_new_turn(state)
end

function solo.start_new_turn(state)
    if solo.check_rage(state) then
        state.rage_active = true
        local results = solo.trigger_rage(state)

        if results.defeat then
            state.game_over = true
            state.victory = false
            state.final_score = solo.calculate_final_score(state)
            state.message = "Defaite ! Le volcan entre en eruption !"
            return
        end

        state.message = "RAGE ! " .. results.cards_revealed .. " carte(s) revelees"
        if results.enemies_placed > 0 then
            state.message = state.message .. ", " .. results.enemies_placed .. " ennemi(s) !"
        end

        solo.recycle_discard(state)
        state.rage_active = false
    end

    local hand = state.zones.hand
    local deck = state.zones.deck
    local needed = config.hand_size - #hand.cards
    if needed > 0 then
        if #deck.cards < needed then
            solo.recycle_discard(state)
        end
        engine.dealCards(deck, hand, needed)
    end

    solo.activate_totems_start_of_turn(state)

    if not state.rage_active then
        state.message = "Tour " .. state.turn .. " — Cliquez une carte Clan pour agir"
    end
end

return solo

Seeing a nil error on game over?

Make sure both the victory block in end_turn() and the defeat block in start_new_turn() now include the state.final_score = solo.calculate_final_score(state) line. If only one path has it, the other will set state.final_score to nil and the score screen will crash.

Help me debug a nil error when my Dinoblivion game ends

Build the score breakdown screen

The scoring logic is ready, but the player cannot see it. You need a visual overlay that appears when state.game_over is true, showing each VP category and a total.

  • In main.lua, find your drawGame() function.
  • At the very end of drawGame(), after all existing drawing code but before the function's closing end, add the score overlay:
    -- Score breakdown overlay
    if state.game_over and state.final_score then
        -- Dim the game board behind the overlay
        love.graphics.setColor(0, 0, 0, 0.85)
        love.graphics.rectangle("fill", 0, 0, 1280, 800)

        -- Header: victory or defeat
        love.graphics.setColor(1, 1, 1)
        local header = state.victory and "VICTOIRE !" or "DEFAITE !"
        love.graphics.printf(header, 0, 120, 1280, "center")

        -- Score breakdown by category
        local s = state.final_score
        local y = 260
        local gap = 40

        love.graphics.printf("Familles : " .. s.families .. " x 2VP = " .. (s.families * 2) .. " VP", 0, y, 1280, "center")
        y = y + gap
        love.graphics.printf("Totems : " .. s.totems .. " x 1VP = " .. (s.totems * 1) .. " VP", 0, y, 1280, "center")
        y = y + gap
        love.graphics.printf("Dinosaures : " .. s.dinos .. " x 1VP = " .. (s.dinos * 1) .. " VP", 0, y, 1280, "center")
        y = y + gap
        love.graphics.printf("Oeufs : " .. s.eggs .. " VP", 0, y, 1280, "center")
        y = y + gap * 2

        -- Total score
        love.graphics.printf("TOTAL : " .. s.total .. " VP", 0, y, 1280, "center")
        y = y + gap * 2

        -- Restart/menu prompt
        love.graphics.printf("R = Recommencer | Echap = Menu", 0, y, 1280, "center")
    end

What does this overlay do?

The overlay draws a nearly opaque black rectangle over the entire 1280x800 canvas to dim the game board. It then renders the score breakdown in white text, centered on screen.

Each scoring category shows its count, the VP multiplier, and the subtotal. The y variable steps down by gap pixels after each line to space the rows evenly.

  • Now find your love.keypressed(key) function in main.lua.
  • Add the following block at the top of the function, before any existing key checks, to handle restart and menu on the game-over screen:
    -- Game-over screen: R to restart, Escape to menu
    if state.game_over then
        if key == "r" then
            initGame()
        elseif key == "escape" then
            gameState = "menu"
        end
        return
    end

Why does this block return early?

The return at the end prevents any other key handlers from firing while the score screen is showing. Without it, pressing a key could accidentally trigger in-game actions behind the overlay.

  • Save main.lua.
  • Run your game by executing this command from the project directory:
love .
  • Play through a game until you either clear all dinosaur piles (victory) or the jungle empties during RAGE (defeat).

You should see a dark overlay appear with "VICTOIRE !" or "DEFAITE !" at the top. Below it, the four scoring categories are listed with their individual VP values and a total at the bottom. Pressing R restarts the game. Pressing Escape returns to the menu.

Score screen not appearing?

Check that the overlay code is inside drawGame() and not accidentally inside another draw function like drawMenu(). The state.game_over flag only exists when the game state machine is in the "playing" state.

If R and Escape do not respond, make sure the game-over key block is at the top of love.keypressed() and that the gameState == "playing" check routes to it correctly.

Help me troubleshoot my Dinoblivion score screen not showing up

Secret mission

Enemy Encounter System

Your RAGE mechanic already reveals cards to territories. But what about the enemies lurking in the jungle? In the full Dinoblivion rules, enemies accumulate on territory edges during RAGE and amplify future reveals. Can you implement the defeat mechanic where players spend force plus specific resource costs to fight back?

Before you go

Clean Up Your Resources

This project runs entirely on your local machine using LOVE2D, so there are no ongoing cloud costs or subscriptions to worry about.

Decide whether to keep your project files as they are, back them up for later, or remove them entirely.

Resources you used:

  • Local lua_dinoB repository with updated game files.
  • New solo_rules.lua module for activation, RAGE, and scoring logic.
  • Updated zones.lua with cave, totem, and egg bank zone definitions.
  • Updated main.lua with action selection UI, activation rendering, and score screen.
  • Updated engine.lua with 90-degree rotation rendering for activated cards.
  • LOVE2D 11.5 runtime installed on your machine.

Keep everything running

No action needed. Choose this if you plan to keep building on your Dinoblivion game or want to playtest and refine the solo rules.

  • Your lua_dinoB folder stays on your Desktop with all game files intact.
  • You can launch the game anytime by running love . from the project directory.
  • Push your latest changes to GitHub to keep your remote repository up to date.

Pause - I'll come back to this later

Back up your work to GitHub so you can pick up where you left off on any machine.

  • Commit and push all changes to your GitHub repository so nothing is lost.
  • LOVE2D uses no background processes, so there is nothing to stop or pause.
  • When you return, clone the repo and run love . to pick up right where you left off.

Delete - I don't want to use this again

Remove all project files from your machine to start fresh.

  • Delete the lua_dinoB folder from your Desktop.
  • If you no longer need LOVE2D for other projects, uninstall it from your system.
  • Optionally delete the GitHub repository if you do not want to keep the remote copy.

Mission accomplished

That's a Wrap!

Nice work! You just implemented the full Dinoblivion solo game rules in LOVE2D, turning a basic card engine into a fully playable solo mode with enforced turn rules, deck-cycling pressure, and a complete scoring system.

You've learned how to:

  • Build a clan card activation system with 5 distinct action types, backed by a state machine that manages idle, action selection, and target selection phases.
  • Implement the RAGE mechanic that punishes slow play by flooding territories with jungle cards when the deck empties, complete with defeat detection if the jungle runs dry.
  • Create a full end-of-game scoring system that calculates victory points from four sources: ami families, totem symbols, defeated dinosaurs, and revealed eggs.
  • Implement the enemy encounter system where enemies accumulate on territories during RAGE, amplify future reveals, and can be defeated by spending force plus specific resource costs.

Ready to quiz yourself?