Skip to main content

How to Make a CCG Game

Copied to clipboard!
No boilerplate memorization required

How to Vibe Code a Collectible Card Game (CCG)

Stay in your creative flow. Direct AI models with high-level game design intent, balance creature & spell synergies, and ship a playable turn-based CCG duel prototype in your browser in under 25 minutes.

Core Stack

HTML5 Canvas + JS

Persistence

LocalStorage / JSON

AI Partner

Claude / Gemini

Target Time

15–25 Mins

The Philosophy

The Vibe Coding CCG Philosophy

Traditional collectible card game development is notorious for complex state machines: turn phases, stack resolution, mana crystal accounting, and drag-and-drop board hitboxes. Vibe coding flips the table. You act as the Lead Card Designer & Game Director: defining creature stats, spell keywords, mana curves, and victory conditions while conversational AI writes the turn engine and Canvas rendering loop.

Old-School CCG Stress

  • Writing hundreds of lines of state machine boilerplate before drawing a single card.
  • Manually debugging hand fan-out geometry and hover hitboxes on Canvas.
  • Hardcoding complex spell targeting callbacks that break when a minion dies.
  • Spending weeks building a custom deck editor before testing core combat fun.

The Vibe Coder Way

  • Single-file HTML5 Canvas + JS starter that runs immediately in any browser tab.
  • Describe the card table vibe: "Hearthstone-style mana crystals, 5-slot creature board, and glowing playable cards."
  • Add new mechanics conversationally: "Add Taunt and Lifesteal keywords to creature cards."
  • Playtest against an AI opponent in seconds and tweak mana costs on the fly.
Execution

The 4-Step Blueprint

From an empty file to battling an AI opponent with creatures, spells, and mana crystals.

Step 01

Create the Single-File Tabletop

Create a single index.html file on your computer. No heavy engine downloads or build bundlers requiredβ€”just pure HTML5 Canvas and vanilla JavaScript.

πŸ’‘ Pro Vibe Tip: Keeping card definitions, turn rules, and board rendering in one index.html lets LLMs see your entire game ruleset at once without losing context.
Step 02

The "One-Shot Architect" Prompt

Prompt your AI with the core CCG loop: 20-card decks, ramping mana crystals (1 to 10), creature cards vs. direct-damage spell cards, and hero life totals (e.g., 20 HP).

πŸ”‘ Key Constraint: Tell the AI: "Output a complete, self-contained index.html with procedural card frames and click-to-play targeting."
Step 03

Synergies, Keywords & Table Juice

Layer in tactical depth and visual feedback: summoning sickness zzz's, green glow outlines on affordable cards, floating damage numbers, and deck-out fatigue rules.

✨ Iteration Secret: Ask the AI to store all card templates in a clean JSON array at the top of the script so you can balance attack, health, and mana cost in seconds.
Step 04

Deckbuilder & Instant Web Share

Save unlocked cards and custom decks to localStorage, then deploy your index.html to GitHub Pages, Netlify, or itch.io for instant browser play.

πŸ† The Grand Payoff: Anyone can play your custom collectible card game right in their desktop or mobile browser with zero installation.
Interactive Tool

Vibe Prompt Configurator

Customize your CCG flavor, mana rules, and tactical mechanics. Copy the generated master prompt into Claude, Cursor, or Gemini.

Special Feature Vibe
Optional custom details added straight into your master prompt.
Generated Master Prompt
Ready to paste into Claude, Gemini, or ChatGPT Single-file HTML5 Canvas ready
Game Design Spec

Essential Elements of a Collectible Card Game

Whether inspired by Magic: The Gathering, Yu-Gi-Oh!, or Hearthstone, every great CCG is built on these six foundational pillars:

  • CCG Archetypes & Combat Styles

    Decide whether your CCG emphasizes tactical creature-vs-creature combat (like Magic and Yu-Gi-Oh!) or fast-paced spell synergies and digital effects (like Hearthstone).

  • Creature & Spell Cards

    Divide cards into two core categories: Creature cards that stay on the board to attack and defend, and Spell cards that trigger immediate one-shot effects like fireballs, buffs, or card draw.

  • Decks & Deckbuilding Synergy

    Players construct decks from cards they own, balancing creature types, spell ratios, and mana curves so they have strong plays in the early, mid, and late turns.

  • Mana Resource Economy

    Every card carries a mana cost. Starting with a small mana pool that grows each turn forces players to make rewarding tactical trade-offs on timing and tempo.

  • Hero Life Total Management

    Life represents how much damage a player can absorb from enemy creatures and spells before defeatβ€”turning lifegain, board control, and face damage into a constant tug-of-war.

  • Clear Victory Conditions

    Define decisive win states: reducing the opponent's life total to zero, forcing them to draw from an empty deck (mill/fatigue), or assembling an alternate point objective.

UX Checklist Before Shipping: Highlight playable cards in green when the player has enough mana, dim sleeping creatures on the turn they are summoned, and always show remaining deck counts.
Starter Template

The "Golden" Single-File CCG Game Template

Save this as index.html and open it in any browser to play a complete turn-based Collectible Card Game right now.

index.html
HTML5 Canvas + Vanilla JS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vibe CCG: Arcane Duel</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; user-select: none; }
  body {
    background: #0b0f19;
    color: #f8fafc;
    font-family: system-ui, -apple-system, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    padding: 12px;
  }
  .hud-bar {
    width: 900px;
    max-width: 100%;
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 8px;
    padding: 8px 14px;
    background: #131c2e;
    border: 1px solid #263554;
    border-radius: 10px;
    font-size: 14px;
  }
  canvas {
    background: radial-gradient(circle at center, #1b2845 0%, #0d1322 100%);
    border: 2px solid #334155;
    border-radius: 14px;
    box-shadow: 0 20px 50px rgba(0,0,0,0.65);
    max-width: 100%;
    height: auto;
    cursor: pointer;
  }
</style>
</head>
<body>
  <div class="hud-bar">
    <div><strong>Vibe CCG ⚑</strong> β€” Click hand cards to summon/cast. Click a ready creature, then click an enemy to attack!</div>
    <div id="status-text">Your Turn</div>
  </div>
  <canvas id="game" width="900" height="580"></canvas>

<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status-text');

const CARD_POOL = [
  { name: 'Shield Squire', cost: 1, atk: 1, hp: 3, type: 'creature', text: 'Sturdy 1-drop' },
  { name: 'Ember Imp', cost: 1, atk: 2, hp: 1, type: 'creature', text: 'Aggressive striker' },
  { name: 'Rune Golem', cost: 2, atk: 2, hp: 4, type: 'creature', text: 'Solid frontline' },
  { name: 'Sky Drake', cost: 3, atk: 4, hp: 3, type: 'creature', text: 'High attack flyer' },
  { name: 'Sun Paladin', cost: 4, atk: 4, hp: 5, type: 'creature', text: 'Battlecry: Heal 2', heal: 2 },
  { name: 'Obsidian Titan', cost: 5, atk: 6, hp: 6, type: 'creature', text: 'Heavy late threat' },
  { name: 'Arcane Bolt', cost: 2, atk: 3, hp: 0, type: 'spell', text: 'Deal 3 dmg to enemy hero' },
  { name: 'Holy Elixir', cost: 2, atk: 0, hp: 4, type: 'spell', text: 'Restore 4 Life & draw 1' }
];

let state = {};

function makeDeck() {
  const deck = [];
  for (let i = 0; i < 16; i++) {
    const base = CARD_POOL[i % CARD_POOL.length];
    deck.push({ ...base, uid: Math.random().toString(36).slice(2) });
  }
  return deck.sort(() => Math.random() - 0.5);
}

function initGame() {
  state = {
    turn: 'player',
    turnNum: 1,
    winner: null,
    selectedAttacker: null,
    banner: 'Your Turn β€” Play cards or attack!',
    player: { hp: 20, maxMana: 1, mana: 1, deck: makeDeck(), hand: [], board: [] },
    ai: { hp: 20, maxMana: 1, mana: 1, deck: makeDeck(), hand: [], board: [] },
    fx: []
  };
  for (let i = 0; i < 3; i++) {
    drawCard(state.player);
    drawCard(state.ai);
  }
  drawCard(state.player);
}

function drawCard(side) {
  if (side.deck.length === 0) {
    side.hp -= 2;
    spawnFx(450, side === state.player ? 500 : 80, 'Empty Deck! -2 HP', '#f43f5e');
    checkWin();
    return;
  }
  if (side.hand.length < 6) {
    side.hand.push(side.deck.pop());
  }
}

function spawnFx(x, y, text, color) {
  state.fx.push({ x, y, text, color, alpha: 1, dy: -1 });
}

function checkWin() {
  if (state.player.hp <= 0) state.winner = 'AI Opponent Wins!';
  else if (state.ai.hp <= 0) state.winner = 'Victory! You Won the Duel!';
}

function playCard(side, index, isPlayer) {
  const card = side.hand[index];
  if (!card || card.cost > side.mana) return false;
  if (card.type === 'creature' && side.board.length >= 5) return false;

  side.mana -= card.cost;
  side.hand.splice(index, 1);

  if (card.type === 'creature') {
    side.board.push({
      ...card,
      curHp: card.hp,
      maxHp: card.hp,
      canAttack: false
    });
    if (card.heal) {
      side.hp = Math.min(25, side.hp + card.heal);
      spawnFx(450, isPlayer ? 430 : 120, '+' + card.heal + ' HP', '#10b981');
    }
  } else {
    const enemy = isPlayer ? state.ai : state.player;
    if (card.atk > 0) {
      enemy.hp -= card.atk;
      spawnFx(450, isPlayer ? 80 : 500, '-' + card.atk + ' Spell!', '#f43f5e');
    }
    if (card.hp > 0) {
      side.hp = Math.min(25, side.hp + card.hp);
      drawCard(side);
      spawnFx(450, isPlayer ? 500 : 80, '+' + card.hp + ' HP & Draw!', '#38bdf8');
    }
  }
  checkWin();
  return true;
}

function endPlayerTurn() {
  if (state.turn !== 'player' || state.winner) return;
  state.selectedAttacker = null;
  state.turn = 'ai';
  state.banner = 'Opponent Thinking...';
  statusEl.textContent = 'Enemy Turn';

  setTimeout(() => {
    state.ai.maxMana = Math.min(10, state.ai.maxMana + 1);
    state.ai.mana = state.ai.maxMana;
    drawCard(state.ai);
    state.ai.board.forEach(c => c.canAttack = true);

    // AI plays affordable cards
    for (let i = state.ai.hand.length - 1; i >= 0; i--) {
      playCard(state.ai, i, false);
    }

    // AI attacks with ready creatures
    state.ai.board.forEach(minion => {
      if (!minion.canAttack || state.winner) return;
      if (state.player.board.length > 0 && Math.random() < 0.6) {
        const target = state.player.board[0];
        target.curHp -= minion.atk;
        minion.curHp -= target.atk;
        spawnFx(450, 320, 'Clash!', '#f59e0b');
      } else {
        state.player.hp -= minion.atk;
        spawnFx(450, 500, '-' + minion.atk + ' HP', '#f43f5e');
      }
      minion.canAttack = false;
    });

    cleanupBoards();
    checkWin();

    if (!state.winner) {
      state.turn = 'player';
      state.turnNum++;
      state.player.maxMana = Math.min(10, state.player.maxMana + 1);
      state.player.mana = state.player.maxMana;
      drawCard(state.player);
      state.player.board.forEach(c => c.canAttack = true);
      state.banner = 'Your Turn β€” Spend Mana & Attack!';
      statusEl.textContent = 'Your Turn (Turn ' + state.turnNum + ')';
    }
  }, 650);
}

function cleanupBoards() {
  state.player.board = state.player.board.filter(c => c.curHp > 0);
  state.ai.board = state.ai.board.filter(c => c.curHp > 0);
}

canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect();
  const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
  const my = (e.clientY - rect.top) * (canvas.height / rect.height);

  if (state.winner) {
    initGame();
    return;
  }
  if (state.turn !== 'player') return;

  // End Turn button
  if (mx >= 760 && mx <= 880 && my >= 265 && my <= 315) {
    endPlayerTurn();
    return;
  }

  // Click hand cards
  const handStart = 450 - (state.player.hand.length * 118) / 2;
  for (let i = 0; i < state.player.hand.length; i++) {
    const cx = handStart + i * 118;
    const cy = 445;
    if (mx >= cx && mx <= cx + 110 && my >= cy && my <= cy + 125) {
      playCard(state.player, i, true);
      return;
    }
  }

  // Click player board creature to select attacker
  const pBoardStart = 450 - (state.player.board.length * 124) / 2;
  for (let i = 0; i < state.player.board.length; i++) {
    const cx = pBoardStart + i * 124;
    const cy = 295;
    if (mx >= cx && mx <= cx + 112 && my >= cy && my <= cy + 115) {
      const unit = state.player.board[i];
      if (unit.canAttack) {
        state.selectedAttacker = i;
        state.banner = 'Select an enemy creature or the Enemy Hero portrait!';
      } else {
        spawnFx(cx + 55, cy, 'Sleeping (Summoned this turn)', '#94a3b8');
      }
      return;
    }
  }

  // If attacker selected, check enemy creature or enemy hero click
  if (state.selectedAttacker !== null) {
    const attacker = state.player.board[state.selectedAttacker];
    if (!attacker) { state.selectedAttacker = null; return; }

    // Enemy board units
    const aiBoardStart = 450 - (state.ai.board.length * 124) / 2;
    for (let i = 0; i < state.ai.board.length; i++) {
      const cx = aiBoardStart + i * 124;
      const cy = 140;
      if (mx >= cx && mx <= cx + 112 && my >= cy && my <= cy + 115) {
        const defender = state.ai.board[i];
        defender.curHp -= attacker.atk;
        attacker.curHp -= defender.atk;
        attacker.canAttack = false;
        state.selectedAttacker = null;
        spawnFx(cx + 55, cy + 40, '-' + attacker.atk, '#f43f5e');
        cleanupBoards();
        checkWin();
        return;
      }
    }

    // Enemy hero hitbox
    if (mx >= 360 && mx <= 540 && my >= 15 && my <= 95) {
      state.ai.hp -= attacker.atk;
      attacker.canAttack = false;
      state.selectedAttacker = null;
      spawnFx(450, 60, '-' + attacker.atk + ' Direct Hit!', '#f43f5e');
      checkWin();
      return;
    }
  }
});

function drawCardBox(x, y, w, h, card, highlightColor, isBoard) {
  ctx.fillStyle = card.type === 'spell' ? '#1e1b4b' : '#1e293b';
  ctx.strokeStyle = highlightColor || '#475569';
  ctx.lineWidth = highlightColor ? 3 : 1.5;
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, 10);
  ctx.fill();
  ctx.stroke();

  // Mana badge
  ctx.fillStyle = '#0284c7';
  ctx.beginPath();
  ctx.arc(x + 18, y + 18, 12, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 13px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(card.cost, x + 18, y + 23);

  // Title
  ctx.fillStyle = '#f8fafc';
  ctx.font = 'bold 12px sans-serif';
  ctx.fillText(card.name, x + w / 2 + 6, y + 22);

  // Type / description
  ctx.fillStyle = '#94a3b8';
  ctx.font = '11px sans-serif';
  ctx.fillText(card.type.toUpperCase(), x + w / 2, y + 48);
  ctx.fillStyle = '#cbd5e1';
  ctx.fillText(card.text, x + w / 2, y + 72);

  if (card.type === 'creature') {
    // Attack badge
    ctx.fillStyle = '#f59e0b';
    ctx.beginPath();
    ctx.arc(x + 20, y + h - 18, 13, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.font = 'bold 13px sans-serif';
    ctx.fillText(card.atk, x + 20, y + h - 13);

    // HP badge
    ctx.fillStyle = '#ef4444';
    ctx.beginPath();
    ctx.arc(x + w - 20, y + h - 18, 13, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#fff';
    ctx.fillText(isBoard ? card.curHp : card.hp, x + w - 20, y + h - 13);
  }
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Center divider line
  ctx.strokeStyle = '#334155';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(20, 280);
  ctx.lineTo(880, 280);
  ctx.stroke();

  // Enemy Hero Portrait
  ctx.fillStyle = '#31102f';
  ctx.strokeStyle = '#f43f5e';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.roundRect(360, 16, 180, 74, 12);
  ctx.fill();
  ctx.stroke();
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 15px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Archmage AI', 450, 42);
  ctx.fillStyle = '#fda4af';
  ctx.font = 'bold 18px sans-serif';
  ctx.fillText('❀️ ' + state.ai.hp + ' HP  |  πŸ’Ž ' + state.ai.mana + '/' + state.ai.maxMana, 450, 72);
  ctx.font = '12px sans-serif';
  ctx.fillStyle = '#94a3b8';
  ctx.fillText('Deck: ' + state.ai.deck.length + ' | Hand: ' + state.ai.hand.length, 110, 45);

  // Player Status Box
  ctx.fillStyle = '#0f292a';
  ctx.strokeStyle = '#10b981';
  ctx.beginPath();
  ctx.roundRect(20, 455, 165, 105, 12);
  ctx.fill();
  ctx.stroke();
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 15px sans-serif';
  ctx.fillText('You (Hero)', 102, 482);
  ctx.fillStyle = '#6ee7b7';
  ctx.font = 'bold 18px sans-serif';
  ctx.fillText('❀️ ' + state.player.hp + ' HP', 102, 510);
  ctx.fillStyle = '#38bdf8';
  ctx.font = 'bold 15px sans-serif';
  ctx.fillText('πŸ’Ž Mana ' + state.player.mana + '/' + state.player.maxMana, 102, 535);
  ctx.fillStyle = '#94a3b8';
  ctx.font = '11px sans-serif';
  ctx.fillText('Deck: ' + state.player.deck.length + ' cards', 102, 553);

  // End Turn Button
  ctx.fillStyle = state.turn === 'player' ? '#10b981' : '#475569';
  ctx.beginPath();
  ctx.roundRect(760, 265, 120, 50, 10);
  ctx.fill();
  ctx.fillStyle = '#062219';
  ctx.font = 'bold 15px sans-serif';
  ctx.fillText('END TURN', 820, 295);

  // AI Board
  const aiStart = 450 - (state.ai.board.length * 124) / 2;
  state.ai.board.forEach((c, idx) => {
    drawCardBox(aiStart + idx * 124, 140, 112, 115, c, '#f43f5e', true);
  });

  // Player Board
  const pStart = 450 - (state.player.board.length * 124) / 2;
  state.player.board.forEach((c, idx) => {
    let border = '#64748b';
    if (state.selectedAttacker === idx) border = '#facc15';
    else if (c.canAttack && state.turn === 'player') border = '#10b981';
    drawCardBox(pStart + idx * 124, 295, 112, 115, c, border, true);
  });

  // Player Hand
  const hStart = 450 - (state.player.hand.length * 118) / 2;
  state.player.hand.forEach((c, idx) => {
    const canPlay = state.turn === 'player' && c.cost <= state.player.mana;
    drawCardBox(hStart + idx * 118, 445, 110, 125, c, canPlay ? '#38bdf8' : null, false);
  });

  // Floating FX
  state.fx.forEach(f => {
    ctx.globalAlpha = Math.max(0, f.alpha);
    ctx.fillStyle = f.color;
    ctx.font = 'bold 18px sans-serif';
    ctx.fillText(f.text, f.x, f.y);
    f.y += f.dy;
    f.alpha -= 0.02;
  });
  ctx.globalAlpha = 1;
  state.fx = state.fx.filter(f => f.alpha > 0);

  if (state.winner) {
    ctx.fillStyle = 'rgba(9, 13, 24, 0.85)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#facc15';
    ctx.font = 'bold 34px sans-serif';
    ctx.fillText(state.winner, 450, 270);
    ctx.fillStyle = '#e2e8f0';
    ctx.font = '18px sans-serif';
    ctx.fillText('Click anywhere to start a new CCG duel', 450, 315);
  }

  requestAnimationFrame(render);
}

initGame();
render();
</script>
</body>
</html>
Troubleshooting

How to Debug Without Losing Your Vibe

Card games have subtle timing and targeting edge cases. Use these three vibe debugging rules when iterating with AI:

Explicit Target State

If clicking a creature and then clicking an enemy feels finicky, tell the AI: "Store selectedAttackerIndex in state and draw a bright yellow border around the active attacker until a target is clicked."

Dead Creature Cleanup

To prevent 0-HP ghosts staying on the board mid-turn, instruct the AI: "Run a cleanupBoards() filter immediately after every spell resolution and creature combat trade."

Lock Input During AI Turn

Prevent double-clicking End Turn or playing cards while the AI is acting by prompting: "Ignore all canvas click events when state.turn !== 'player' except when the game-over overlay is active."

Extensions

The Vibe Prompt Vault

Copy-paste prompts to expand your CCG with deckbuilding, keyword abilities, and pack openings.

πŸ›‘οΈ Prompt: Add Guard (Taunt), Lifesteal, and Deathrattle Keywords
Prompt Template

"I have a working HTML5 Canvas CCG in index.html. Add three keyword abilities to creature cards: 1) 'Guard' (draws a silver shield border; enemy creatures must attack Guard creatures before hitting other targets or the Hero), 2) 'Lifesteal' (damage dealt heals the owner's Hero), and 3) 'Deathrattle' (triggers an effect like drawing a card when the creature dies)."

πŸ“š Prompt: Build a Pre-Match Deckbuilder & Booster Pack Opener
Prompt Template

"Extend my single-file CCG with a 'Collection & Deckbuilder' tab saved in localStorage. Reward the player with 100 gold per duel win, let them spend 100 gold to open a 3-card booster pack with animated card flip reveals, and let them customize their 16-card active deck with a live mana curve bar chart."

✨ Prompt: Add Bezier Targeting Arrows & Screen-Shake Attack Animations
Prompt Template

"Upgrade the visual game feel of my Canvas CCG. When a player selects a ready creature on the board, draw a glowing dashed red Bezier targeting arrow from that card to the mouse cursor. When an attack or spell lands, animate a quick lunge toward the target, spawn particle sparks, and trigger a subtle 150ms screen shake."

We use cookies for ads and analytics.