How to Make a CCG Game
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 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.
The 4-Step Blueprint
From an empty file to battling an AI opponent with creatures, spells, and mana crystals.
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.
index.html lets LLMs see your entire game ruleset at once without losing context.
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).
index.html with procedural card frames and click-to-play targeting."
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.
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.
Vibe Prompt Configurator
Customize your CCG flavor, mana rules, and tactical mechanics. Copy the generated master prompt into Claude, Cursor, or Gemini.
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.
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.
<!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>
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."
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
"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
"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
"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."