Carrom Game - Day 16
Canvas + PhysicsCarrom requires precise physics constants to achieve the authentic feel of pieces sliding on a polished wooden board. The friction coefficient is critical for realistic deceleration.
// Board dimensions
const BOARD_SIZE = 600; // Base board size in pixels
const POCKET_RADIUS = 22; // Corner pocket size
const PIECE_RADIUS = 14; // Standard piece size
const STRIKER_RADIUS = 18; // Striker is larger and heavier
// Physics constants
const FRICTION = 0.985; // Speed multiplier per frame
const MIN_VELOCITY = 0.1; // Threshold to stop movement
const MAX_POWER = 25; // Maximum shot force
const BASELINE_OFFSET = 80; // Distance from edge to baseline
// Core game state variables
let pieces = []; // All carrom pieces
let striker = null; // Player's striker
let currentPlayer = 1; // 1 = black, 2 = white
let scores = [0, 0]; // Player scores
let piecesMoving = false; // Physics active flag
let queenPocketed = false; // Queen state tracking
let queenNeedsCover = [false, false]; // Cover rule
// Piece data structure
const piece = {
x: 300, // X position
y: 300, // Y position
vx: 0, // X velocity
vy: 0, // Y velocity
radius: 14, // Collision radius
type: 'black', // 'black', 'white', 'queen', 'striker'
pocketed: false // In pocket flag
};
The friction value of 0.985 means pieces retain 98.5% of their velocity each frame. At 60fps, this creates a natural deceleration curve that mimics real carrom board powder.
Traditional Carrom has a specific starting arrangement with the Queen in the center, surrounded by alternating black and white pieces in concentric circles.
function createPieces() {
pieces = [];
const center = canvas.width / 2;
const scale = canvas.width / BOARD_SIZE;
const pieceR = PIECE_RADIUS * scale;
// Queen in center (red piece)
pieces.push({
x: center,
y: center,
vx: 0, vy: 0,
radius: pieceR,
type: 'queen',
color: '#dc2626',
pocketed: false
});
// Inner ring - 6 pieces at radius 2.5x piece size
const innerRadius = pieceR * 2.5;
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2 - Math.PI / 2;
const type = i % 2 === 0 ? 'black' : 'white';
pieces.push({
x: center + Math.cos(angle) * innerRadius,
y: center + Math.sin(angle) * innerRadius,
vx: 0, vy: 0,
radius: pieceR,
type: type,
pocketed: false
});
}
// Outer ring - 12 pieces at radius 4.5x piece size
const outerRadius = pieceR * 4.5;
for (let i = 0; i < 12; i++) {
// Offset by half to stagger with inner ring
const angle = (i / 12) * Math.PI * 2 + Math.PI / 12;
const type = i % 2 === 0 ? 'black' : 'white';
pieces.push({
x: center + Math.cos(angle) * outerRadius,
y: center + Math.sin(angle) * outerRadius,
radius: pieceR,
type: type
});
}
}
Piece Arrangement Pattern
function updatePockets() {
const s = canvas.width;
const offset = 30; // Distance from corner
pockets.length = 0;
pockets.push(
{ x: offset, y: offset }, // Top-left
{ x: s - offset, y: offset }, // Top-right
{ x: offset, y: s - offset }, // Bottom-left
{ x: s - offset, y: s - offset } // Bottom-right
);
}
The physics loop updates positions based on velocity, applies friction to simulate the powder-coated board surface, and handles boundary collisions with the board frame.
function update() {
if (!gameRunning) return;
piecesMoving = false;
// Update all active pieces
for (const piece of pieces) {
if (piece.pocketed) continue;
// Apply velocity to position
piece.x += piece.vx;
piece.y += piece.vy;
// Apply friction (simulates board powder)
piece.vx *= FRICTION;
piece.vy *= FRICTION;
// Check if still moving
if (Math.abs(piece.vx) > MIN_VELOCITY ||
Math.abs(piece.vy) > MIN_VELOCITY) {
piecesMoving = true;
} else {
// Stop completely below threshold
piece.vx = 0;
piece.vy = 0;
}
// Handle boundary and pocket collisions
boundaryCollision(piece);
checkPocketCollision(piece);
}
// Check piece-to-piece collisions
checkAllCollisions();
}
function boundaryCollision(obj) {
const s = canvas.width;
const margin = 35 * (s / BOARD_SIZE); // Frame width
const boardMin = margin;
const boardMax = s - margin;
// Left wall
if (obj.x - obj.radius < boardMin) {
obj.x = boardMin + obj.radius;
obj.vx *= -0.8; // Energy loss on bounce
}
// Right wall
if (obj.x + obj.radius > boardMax) {
obj.x = boardMax - obj.radius;
obj.vx *= -0.8;
}
// Top wall
if (obj.y - obj.radius < boardMin) {
obj.y = boardMin + obj.radius;
obj.vy *= -0.8;
}
// Bottom wall
if (obj.y + obj.radius > boardMax) {
obj.y = boardMax - obj.radius;
obj.vy *= -0.8;
}
}
The -0.8 multiplier creates an 80% energy retention on wall bounces. This simulates the rubber cushions on a real Carrom board frame.
When two circular pieces collide, we calculate the collision normal and apply impulse-based physics to achieve realistic momentum transfer.
function checkCollision(a, b) {
// Distance between centers
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = a.radius + b.radius;
// No collision if not overlapping
if (dist >= minDist || dist === 0) return;
// Calculate collision normal (unit vector)
const nx = dx / dist;
const ny = dy / dist;
// Relative velocity along collision normal
const dvx = a.vx - b.vx;
const dvy = a.vy - b.vy;
const dvn = dvx * nx + dvy * ny;
// Only resolve if objects are approaching
if (dvn <= 0) return;
// Mass-based impulse (striker is heavier)
const ma = a.type === 'striker' ? 2 : 1;
const mb = b.type === 'striker' ? 2 : 1;
const totalMass = ma + mb;
// Calculate impulse magnitude
const impulse = (2 * dvn) / totalMass;
const restitution = 0.95; // Near-elastic collision
// Apply impulse to velocities
a.vx -= impulse * mb * nx * restitution;
a.vy -= impulse * mb * ny * restitution;
b.vx += impulse * ma * nx * restitution;
b.vy += impulse * ma * ny * restitution;
}
// After velocity resolution, separate overlapping pieces
const overlap = minDist - dist;
const separationX = (overlap / 2 + 0.5) * nx;
const separationY = (overlap / 2 + 0.5) * ny;
// Push objects apart equally
a.x -= separationX;
a.y -= separationY;
b.x += separationX;
b.y += separationY;
Collision Resolution Pipeline
The +0.5 in separation ensures pieces don't get stuck together. This small buffer prevents numerical precision issues from causing repeated collisions.
Pockets are circular regions in the corners. When a piece's center enters the pocket radius, it's considered pocketed and removed from play.
function checkPocketCollision(obj) {
const pocketR = POCKET_RADIUS * (canvas.width / BOARD_SIZE);
for (const pocket of pockets) {
const dx = obj.x - pocket.x;
const dy = obj.y - pocket.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < pocketR) {
// Piece has entered the pocket!
obj.pocketed = true;
obj.vx = 0;
obj.vy = 0;
// Handle based on piece type
if (obj.type === 'striker') {
foul = true; // Pocketing striker is a foul
} else {
pocketedThisTurn.push(obj);
handleScore(obj);
}
break;
}
}
}
function handleScore(piece) {
if (piece.type === 'black') {
scores[0]++;
} else if (piece.type === 'white') {
scores[1]++;
} else if (piece.type === 'queen') {
queenPocketed = true;
// Mark that current player needs to cover
queenNeedsCover[currentPlayer - 1] = true;
}
}
function returnPieceToCenter() {
const ownColor = currentPlayer === 1 ? 'black' : 'white';
// Find a pocketed piece of player's color
const pocketedPieces = pieces.filter(
p => p.pocketed && p.type === ownColor
);
if (pocketedPieces.length > 0) {
// Return the most recently pocketed piece
const piece = pocketedPieces[pocketedPieces.length - 1];
piece.pocketed = false;
piece.x = canvas.width / 2;
piece.y = canvas.height / 2;
piece.vx = 0;
piece.vy = 0;
scores[currentPlayer - 1]--;
}
}
Carrom has specific rules about turn continuation, the Queen's "cover" requirement, and win conditions that must be carefully implemented.
function handleTurnEnd() {
if (piecesMoving) return;
// Check win conditions first
const blackLeft = pieces.filter(
p => p.type === 'black' && !p.pocketed
).length;
const whiteLeft = pieces.filter(
p => p.type === 'white' && !p.pocketed
).length;
if (blackLeft === 0 || whiteLeft === 0) {
endGame();
return;
}
// Handle striker foul (pocketed striker)
if (striker && striker.pocketed) {
foul = true;
returnPieceToCenter();
}
// Queen cover rule check
checkQueenCover();
// Determine if turn continues
const ownColor = currentPlayer === 1 ? 'black' : 'white';
const pocketedOwn = pocketedThisTurn.some(
p => p.type === ownColor
);
// Switch turns if no own piece pocketed or foul
if (!pocketedOwn || foul) {
currentPlayer = currentPlayer === 1 ? 2 : 1;
}
// Reset for next turn
foul = false;
pocketedThisTurn = [];
createStriker();
}
function checkQueenCover() {
for (let p = 0; p < 2; p++) {
if (!queenNeedsCover[p]) continue;
const ownColor = p === 0 ? 'black' : 'white';
// Check if they covered the queen this turn
const coveredQueen = pocketedThisTurn.some(
piece => piece.type === ownColor
);
if (!coveredQueen) {
// Queen goes back to center!
const queen = pieces.find(
piece => piece.type === 'queen'
);
if (queen) {
queen.pocketed = false;
queen.x = canvas.width / 2;
queen.y = canvas.height / 2;
}
queenPocketed = false;
} else {
// Queen successfully covered - bonus points!
scores[p] += 3;
}
queenNeedsCover[p] = false;
}
}
In traditional Carrom, after pocketing the Queen, you must "cover" it by pocketing one of your own pieces in the same turn or the next. If you fail, the Queen returns to the center. Successfully covering awards bonus points!
function positionStriker(pos) {
const s = canvas.width;
const scale = s / BOARD_SIZE;
const borderWidth = 35 * scale;
// Each player has their own baseline
const baselineY = currentPlayer === 1
? s - BASELINE_OFFSET * scale // Bottom for player 1
: BASELINE_OFFSET * scale; // Top for player 2
// Constrain striker to baseline
striker.y = baselineY;
// Limit horizontal movement within baseline circles
striker.x = Math.max(
borderWidth + 60 * scale + striker.radius,
Math.min(
s - borderWidth - 60 * scale - striker.radius,
pos.x
)
);
}
Turn Flow