feat(holdem): support up to 7 AI opponents with dynamic elliptical seating

- Update Holdem game configuration to allow 3-7 AI opponents (previously fixed at 3)
- Implement dynamic seat layout using an elliptical distribution for balanced positioning
- Add computeSeatLayout() to calculate positions based on opponent count, with special handling for side/diagonal corners
- Adjust portrait, card, and UI element positioning to adapt to different table sizes
- Update AI personality definitions to include 7 distinct profiles with varied aggression/bluff rates
- Modify AI decision logic to limp more frequently in unraised preflop scenarios
- Update hand summary modal to scale rows dynamically to fit 7+ players
- Update server game registry to reflect new max opponent count
This commit is contained in:
Brian Fertig 2026-05-24 23:37:09 -06:00
parent 24a48c13eb
commit a05d8b6c96
4 changed files with 145 additions and 51 deletions

View File

@ -1,10 +1,14 @@
import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js'; import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js';
// Per-seat personalities indexed by seat number (13 for AI seats) // Per-seat personalities indexed by seat number (17 for AI seats)
const PERSONALITIES = { const PERSONALITIES = {
1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 }, // Marcus 1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 },
2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 }, // Sofia 2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 },
3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 }, // third AI 3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 },
4: { style: 'aggressive', raiseFactor: 0.62, bluffRate: 0.30 },
5: { style: 'tight', raiseFactor: 0.22, bluffRate: 0.06 },
6: { style: 'loose', raiseFactor: 0.50, bluffRate: 0.18 },
7: { style: 'aggressive', raiseFactor: 0.48, bluffRate: 0.14 },
}; };
// Chen formula approximation: 020 score for pre-flop hand strength // Chen formula approximation: 020 score for pre-flop hand strength
@ -65,7 +69,11 @@ export function chooseAction(state, seat) {
if (Math.random() < pers.bluffRate) strength = Math.min(1, strength + 0.40); if (Math.random() < pers.bluffRate) strength = Math.min(1, strength + 0.40);
// Fold threshold: fold if strength is below the price of calling // Fold threshold: fold if strength is below the price of calling
const foldThreshold = odds * 0.88; let foldThreshold = odds * 0.88;
// When the pot hasn't been raised pre-flop it only costs the big blind to see
// the flop, so opponents limp in with almost anything instead of folding.
const unraisedPreflop = state.phase === 'preflop' && state.roundBet <= (state.blind?.big ?? state.roundBet);
if (unraisedPreflop) foldThreshold *= 0.25;
if (isCheck) { if (isCheck) {
// Check or bet // Check or bet

View File

@ -21,14 +21,13 @@ const CARD_W = 100;
const CARD_H = 140; const CARD_H = 140;
const CARD_R = 8; // corner radius const CARD_R = 8; // corner radius
// Seat screen positions: [human, left-AI, top-AI, right-AI] // Opponents are spaced evenly around a seating ellipse so the table stays
// portraitX/portraitY override the default world position when set. // balanced for any count (37 opponents). The human owns the bottom of the ring
const SEAT_POS = [ // (fixed bottom-centre). Per-game positions are computed in computeSeatLayout().
{ x: CX, y: 900, portraitR: 85, portraitX: CX - 210, portraitY: 900 }, // 0 — Human: portrait left of cards const SEAT_ELLIPSE = { cx: CX, cy: 540, rx: 760, ry: 330 };
{ x: 220, y: 470, portraitR: 78 }, // 1 — AI left // Diagonal corner seats (top/bottom on either side) are pulled this far toward
{ x: CX, y: 140, portraitR: 70, portraitX: CX - 175, portraitY: 140 }, // 2 — AI top: portrait left of cards // the table centre so they don't hug the screen edges.
{ x: 1700, y: 470, portraitR: 78 }, // 3 — AI right const CORNER_NUDGE = 60;
];
// Depth layers // Depth layers
const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, modal: 50 }; const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, modal: 50 };
@ -175,31 +174,107 @@ export default class HoldemGame extends Phaser.Scene {
// ── Seat containers ───────────────────────────────────────────────────────── // ── Seat containers ─────────────────────────────────────────────────────────
// Build per-game seat positions: human bottom-centre, opponents fanned evenly
// across the top arc of the seating ellipse (matches the legacy left/top/right
// look at 3 opponents, fans out to 7).
computeSeatLayout() {
const numOpp = Math.min(this.opponents.length, 7);
const portraitR = numOpp >= 5 ? 58 : 72;
const { cx, cy, rx, ry } = SEAT_ELLIPSE;
const seats = [
{ x: CX, y: 900, portraitR: 85, portraitX: CX - 210, portraitY: 900 }, // human
];
// Spread opponents evenly around the whole ellipse. The human owns the
// bottom slot (270°); opponents fill the remaining ring symmetrically.
const cardHalfSpan = (CARD_W * 0.7 + 8) / 2 + CARD_W / 2; // half-width of an opponent's card pair
const n = numOpp + 1;
for (let i = 1; i <= numOpp; i++) {
const deg = 270 - (i * 360) / n;
const rad = (deg * Math.PI) / 180;
const ex = cx + rx * Math.cos(rad); // outer (on-ellipse) anchor
const ey = cy - ry * Math.sin(rad);
// Seats near the horizontal axis are "side" seats: cards pulled toward the
// table interior, portrait beside them on the outer edge (vertically
// centred), name/chips stacked below the portrait.
if (Math.abs(ey - CY) <= 90) {
const interiorSign = ex < CX ? 1 : -1;
const cardX = ex + interiorSign * (portraitR + 14 + cardHalfSpan);
seats.push({
x: cardX, y: ey, portraitR,
portraitX: ex, portraitY: ey,
side: ex < CX ? 'left' : 'right', interiorSign,
});
continue;
}
// Pull diagonal corner seats inward (seats on the vertical centre axis stay put).
const ax = ex + (Math.abs(ex - cx) > 1 ? (ex < cx ? CORNER_NUDGE : -CORNER_NUDGE) : 0);
const flipped = ey > CY; // bottom-of-table seats: portrait below, cards toward centre
let portraitX, portraitY;
if (flipped) {
portraitX = ax;
portraitY = ey + CARD_H / 2 + portraitR + 8;
} else {
// Portrait sits above the cards; if that clips the top edge, place it
// beside the cards (toward the nearest screen edge) at card height.
const aboveY = ey - CARD_H / 2 - portraitR - 8;
if (aboveY >= portraitR + 4) {
portraitX = ax;
portraitY = aboveY;
} else {
const spacing = CARD_W * 0.7 + 8;
const sideSign = ax <= CX ? -1 : 1;
portraitX = ax + sideSign * (spacing / 2 + portraitR + 12);
portraitY = ey;
}
}
seats.push({ x: ax, y: ey, portraitR, portraitX, portraitY, flipped });
}
this.seatPos = seats;
}
buildSeatContainers() { buildSeatContainers() {
for (let seat = 0; seat < 4; seat++) { this.computeSeatLayout();
const { x, y } = SEAT_POS[seat]; for (let seat = 0; seat < this.seatPos.length; seat++) {
const sp = this.seatPos[seat];
const { x, y, flipped, side } = sp;
const isHuman = seat === 0; const isHuman = seat === 0;
const container = this.add.container(x, y).setDepth(D.cards); const container = this.add.container(x, y).setDepth(D.cards);
// Label positions (local to the seat container). Side seats stack their
// name/chips directly below the portrait, which sits beside the cards;
// other seats keep them on the table-centre side of the cards.
let nameX = 0, chipX = 0, betX = 0, nameY, chipY, betY;
if (isHuman) {
nameY = -CARD_H / 2 - 48; chipY = -CARD_H / 2 - 24; betY = -CARD_H / 2 - 80;
} else if (side) {
nameX = chipX = sp.portraitX - x;
nameY = sp.portraitR + 26; chipY = sp.portraitR + 52;
betX = sp.interiorSign * 140; betY = 0;
} else if (flipped) {
nameY = -CARD_H / 2 - 28; chipY = -CARD_H / 2 - 52; betY = -CARD_H / 2 - 80;
} else {
nameY = CARD_H / 2 + 28; chipY = CARD_H / 2 + 52; betY = CARD_H / 2 + 80;
}
// Name label // Name label
const nameY = isHuman ? -CARD_H / 2 - 48 : CARD_H / 2 + 28; const name = this.add.text(nameX, nameY, '—', {
const name = this.add.text(0, nameY, '—', {
fontFamily: '"Julius Sans One"', fontFamily: '"Julius Sans One"',
fontSize: '18px', fontSize: '18px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
// Chip count // Chip count
const chipY = isHuman ? -CARD_H / 2 - 24 : CARD_H / 2 + 52; const chipTxt = this.add.text(chipX, chipY, '$0', {
const chipTxt = this.add.text(0, chipY, '$0', {
fontFamily: '"Julius Sans One"', fontFamily: '"Julius Sans One"',
fontSize: '20px', fontSize: '20px',
color: '#f0e8d0', color: '#f0e8d0',
}).setOrigin(0.5); }).setOrigin(0.5);
// Bet display (shown at table edge between seat and center) // Bet display (shown between the seat and the table centre)
const betY = isHuman ? -CARD_H / 2 - 80 : (seat === 2 ? CARD_H / 2 + 80 : (y > CY ? -CARD_H / 2 - 80 : CARD_H / 2 + 80)); const betTxt = this.add.text(betX, betY, '', {
const betTxt = this.add.text(0, betY, '', {
fontFamily: '"Julius Sans One"', fontFamily: '"Julius Sans One"',
fontSize: '18px', fontSize: '18px',
color: '#ffd700', color: '#ffd700',
@ -225,7 +300,7 @@ export default class HoldemGame extends Phaser.Scene {
this.seatContainers.push({ container, name, chipTxt, betTxt, cardObjs, dealerChip, dealerTxt }); this.seatContainers.push({ container, name, chipTxt, betTxt, cardObjs, dealerChip, dealerTxt });
// Portrait (world-space — outside the container) // Portrait (world-space — outside the container)
const { x: px, y: py, portraitR, portraitX, portraitY: portraitYOverride } = SEAT_POS[seat]; const { x: px, y: py, portraitR, portraitX, portraitY: portraitYOverride } = this.seatPos[seat];
const portraitWorldX = portraitX ?? px; const portraitWorldX = portraitX ?? px;
const portraitY = portraitYOverride ?? (isHuman const portraitY = portraitYOverride ?? (isHuman
? py - CARD_H / 2 - 48 - portraitR - 8 // above name label ? py - CARD_H / 2 - 48 - portraitR - 8 // above name label
@ -345,8 +420,10 @@ export default class HoldemGame extends Phaser.Scene {
if (check) { if (check) {
this.callBtn.setLabel('Check'); this.callBtn.setLabel('Check');
this.raiseBtn.setLabel('Bet');
} else { } else {
this.callBtn.setLabel(`Call $${toCall}`); this.callBtn.setLabel(`Call $${toCall}`);
this.raiseBtn.setLabel('Raise');
} }
} }
@ -406,7 +483,7 @@ export default class HoldemGame extends Phaser.Scene {
// ── Game flow ──────────────────────────────────────────────────────────────── // ── Game flow ────────────────────────────────────────────────────────────────
beginGame() { beginGame() {
this.gs = createInitialState(this.opponents.slice(0, 3), this.buyIn); this.gs = createInitialState(this.opponents.slice(0, 7), this.buyIn);
this.dealNextHand(); this.dealNextHand();
} }
@ -521,13 +598,13 @@ export default class HoldemGame extends Phaser.Scene {
// Returns world coords of the "bet zone" in front of a seat (between seat and table centre). // Returns world coords of the "bet zone" in front of a seat (between seat and table centre).
betZone(seat) { betZone(seat) {
const { x, y } = SEAT_POS[seat]; const { x, y } = this.seatPos[seat];
const dx = CX - x; const dx = CX - x;
const dy = CY - y; const dy = CY - y;
const len = Math.sqrt(dx * dx + dy * dy); const len = Math.sqrt(dx * dx + dy * dy);
// Seats 0 and 2 sit on the vertical centre axis; shift their bet zone right // Seats on the vertical centre axis shift their bet zone right so chip
// so chip stacks don't overlap the name/chip-count text centred at CX. // stacks don't overlap the name/chip-count text centred at CX.
const xOffset = (seat === 0 || seat === 2) ? 160 : 0; const xOffset = Math.abs(x - CX) < 120 ? 160 : 0;
return { x: x + (dx / len) * 140 + xOffset, y: y + (dy / len) * 100 }; return { x: x + (dx / len) * 140 + xOffset, y: y + (dy / len) * 100 };
} }
@ -544,7 +621,7 @@ export default class HoldemGame extends Phaser.Scene {
animateFold(seat, onComplete) { animateFold(seat, onComplete) {
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
const sc = this.seatContainers[seat]; const sc = this.seatContainers[seat];
const { x: sx, y: sy } = SEAT_POS[seat]; const { x: sx, y: sy } = this.seatPos[seat];
const { x: tx, y: ty } = this.betZone(seat); const { x: tx, y: ty } = this.betZone(seat);
const tempCards = sc.cardObjs const tempCards = sc.cardObjs
@ -569,8 +646,8 @@ export default class HoldemGame extends Phaser.Scene {
if (tempCards.length === 0) { onComplete(); return; } if (tempCards.length === 0) { onComplete(); return; }
// Slight rotation: clockwise for left seats, counter for right // Slight rotation: tilt toward the table edge based on which side the seat is on
const angle = seat === 1 ? -14 : seat === 3 ? 14 : seat === 0 ? 10 : -10; const angle = seat === 0 ? 10 : (sx < CX ? -14 : sx > CX ? 14 : -10);
this.tweens.add({ this.tweens.add({
targets: tempCards, targets: tempCards,
@ -590,7 +667,7 @@ export default class HoldemGame extends Phaser.Scene {
} }
animateChips(seat, onComplete) { animateChips(seat, onComplete) {
const { x: sx, y: sy } = SEAT_POS[seat]; const { x: sx, y: sy } = this.seatPos[seat];
const { x: tx, y: ty } = this.betZone(seat); const { x: tx, y: ty } = this.betZone(seat);
playChipBet(this); playChipBet(this);
@ -764,8 +841,13 @@ export default class HoldemGame extends Phaser.Scene {
// Animate hole cards flying from the deck (centre) to each seat, one at a time. // Animate hole cards flying from the deck (centre) to each seat, one at a time.
animateDeal(onComplete) { animateDeal(onComplete) {
// Deal order: one pass around the table, then a second pass (standard poker) // Deal order: opponents around the table (seats 1..n-1) then the human (0),
const seatOrder = [1, 2, 3, 0].filter((s) => !this.gs.players[s]?.eliminated); // one pass, then a second pass (standard poker)
const n = this.gs.players.length;
const order = [];
for (let s = 1; s < n; s++) order.push(s);
order.push(0);
const seatOrder = order.filter((s) => !this.gs.players[s]?.eliminated);
const sequence = [...seatOrder, ...seatOrder]; // card 0 then card 1 const sequence = [...seatOrder, ...seatOrder]; // card 0 then card 1
let done = 0; let done = 0;
@ -774,7 +856,7 @@ export default class HoldemGame extends Phaser.Scene {
sequence.forEach((seat, idx) => { sequence.forEach((seat, idx) => {
const cardIdx = idx < seatOrder.length ? 0 : 1; const cardIdx = idx < seatOrder.length ? 0 : 1;
const { x: sx, y: sy } = SEAT_POS[seat]; const { x: sx, y: sy } = this.seatPos[seat];
const isHuman = seat === 0; const isHuman = seat === 0;
const spacing = isHuman ? CARD_W + 12 : CARD_W * 0.7 + 8; const spacing = isHuman ? CARD_W + 12 : CARD_W * 0.7 + 8;
const targetX = sx + (cardIdx === 0 ? -spacing / 2 : spacing / 2); const targetX = sx + (cardIdx === 0 ? -spacing / 2 : spacing / 2);
@ -999,9 +1081,11 @@ export default class HoldemGame extends Phaser.Scene {
// ── Action badge ───────────────────────────────────────────────────────────── // ── Action badge ─────────────────────────────────────────────────────────────
showActionBadge(seat, action) { showActionBadge(seat, action) {
const pos = SEAT_POS[seat]; const pos = this.seatPos[seat];
const isHuman = seat === 0; const isHuman = seat === 0;
const badgeY = isHuman ? pos.y - CARD_H / 2 - 110 : pos.y + CARD_H / 2 + 70; const badgeY = isHuman
? pos.y - CARD_H / 2 - 110
: pos.flipped ? pos.y - CARD_H / 2 - 70 : pos.y + CARD_H / 2 + 70;
const labels = { fold: 'FOLD', check: 'CHECK', call: 'CALL', raise: `RAISE $${action.amount ?? ''}`, allin: 'ALL IN' }; const labels = { fold: 'FOLD', check: 'CHECK', call: 'CALL', raise: `RAISE $${action.amount ?? ''}`, allin: 'ALL IN' };
const colors = { fold: '#e06c75', check: '#9e9080', call: COLORS.accentHex, raise: '#ffd700', allin: '#ff6b35' }; const colors = { fold: '#e06c75', check: '#9e9080', call: COLORS.accentHex, raise: '#ffd700', allin: '#ff6b35' };
@ -1079,12 +1163,14 @@ export default class HoldemGame extends Phaser.Scene {
const divider = this.add.rectangle(CX, 262, 1600, 1, COLORS.muted, 0.4).setDepth(D.modal + 1); const divider = this.add.rectangle(CX, 262, 1600, 1, COLORS.muted, 0.4).setDepth(D.modal + 1);
S.push(divider); S.push(divider);
// Player rows — one per seat, staggered // Player rows — one per seat, staggered. Pitch shrinks to fit up to 8 rows
// between the divider and the Continue button.
const rowStartY = 305; const rowStartY = 305;
const rowH = 155; const rowH = Math.min(155, 700 / players.length);
const scale = Math.min(1, rowH / 155);
players.forEach((player, seat) => { players.forEach((player, seat) => {
const rowY = rowStartY + seat * rowH; const rowY = rowStartY + seat * rowH;
this.buildSummaryRow(player, rowY, winnings, 500 + seat * 190, community, S); this.buildSummaryRow(player, rowY, winnings, 500 + seat * 190, community, S, rowH, scale);
}); });
// Continue button — fades in after rows have appeared // Continue button — fades in after rows have appeared
@ -1096,11 +1182,12 @@ export default class HoldemGame extends Phaser.Scene {
this.tweens.add({ targets: continueBtn, alpha: 1, duration: 300, delay: 1100 }); this.tweens.add({ targets: continueBtn, alpha: 1, duration: 300, delay: 1100 });
} }
buildSummaryRow(player, rowY, winnings, delay, community, S) { buildSummaryRow(player, rowY, winnings, delay, community, S, rowH = 155, scale = 1) {
const isWinner = (winnings.get(player.seat) ?? 0) > 0; const isWinner = (winnings.get(player.seat) ?? 0) > 0;
// Row panel // Row panel
const panel = this.add.rectangle(CX, rowY, 1720, 140, COLORS.panel) const panelH = Math.max(70, rowH - 14);
const panel = this.add.rectangle(CX, rowY, 1720, panelH, COLORS.panel)
.setStrokeStyle(2, isWinner ? 0xffd700 : 0x2a2a3a) .setStrokeStyle(2, isWinner ? 0xffd700 : 0x2a2a3a)
.setDepth(D.modal + 1).setAlpha(0); .setDepth(D.modal + 1).setAlpha(0);
S.push(panel); S.push(panel);
@ -1108,7 +1195,7 @@ export default class HoldemGame extends Phaser.Scene {
// Portrait backing // Portrait backing
const portX = 145; const portX = 145;
const portR = 52; const portR = Math.round(52 * scale);
const backing = this.add.circle(portX, rowY, portR + 3, 0x1a1a2e) const backing = this.add.circle(portX, rowY, portR + 3, 0x1a1a2e)
.setDepth(D.modal + 1).setAlpha(0); .setDepth(D.modal + 1).setAlpha(0);
S.push(backing); S.push(backing);
@ -1152,7 +1239,7 @@ export default class HoldemGame extends Phaser.Scene {
} else { } else {
const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase();
const initTxt = this.add.text(portX, rowY, initial, { const initTxt = this.add.text(portX, rowY, initial, {
fontFamily: 'Righteous', fontSize: '46px', color: COLORS.accentHex, fontFamily: 'Righteous', fontSize: `${Math.round(46 * scale)}px`, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(D.modal + 2).setAlpha(0); }).setOrigin(0.5).setDepth(D.modal + 2).setAlpha(0);
S.push(initTxt); S.push(initTxt);
this.tweens.add({ targets: initTxt, alpha: 1, duration: 350, delay }); this.tweens.add({ targets: initTxt, alpha: 1, duration: 350, delay });
@ -1187,15 +1274,14 @@ export default class HoldemGame extends Phaser.Scene {
this.tweens.add({ targets: statusTxt, alpha: 1, duration: 350, delay }); this.tweens.add({ targets: statusTxt, alpha: 1, duration: 350, delay });
} }
// Hole cards (0.82× scale → 82×115) // Hole cards (0.82× scale → 82×115, shrinks further when rows are compact)
const CW = Math.round(CARD_W * 0.82); const cardScale = 0.82 * scale;
const CH = Math.round(CARD_H * 0.82);
const cardBaseX = 490; const cardBaseX = 490;
const cardOffsets = [-48, 48]; const cardOffsets = [-48 * scale, 48 * scale];
if (player.hand?.length === 2) { if (player.hand?.length === 2) {
player.hand.forEach((card, ci) => { player.hand.forEach((card, ci) => {
const cx = cardBaseX + cardOffsets[ci]; const cx = cardBaseX + cardOffsets[ci];
const cont = this.add.container(cx, rowY).setDepth(D.modal + 2).setScale(0.82).setAlpha(0); const cont = this.add.container(cx, rowY).setDepth(D.modal + 2).setScale(cardScale).setAlpha(0);
if (player.folded) { if (player.folded) {
this.renderCard(cont, card, false); this.renderCard(cont, card, false);
this.tweens.add({ targets: cont, alpha: 0.28, duration: 350, delay }); this.tweens.add({ targets: cont, alpha: 0.28, duration: 350, delay });

View File

@ -254,7 +254,7 @@ export function createInitialState(opponentDefs, buyIn) {
eliminated: false, eliminated: false,
hasActedThisRound: false, hasActedThisRound: false,
}, },
...opponentDefs.slice(0, 3).map((opp, i) => ({ ...opponentDefs.slice(0, 7).map((opp, i) => ({
seat: i + 1, seat: i + 1,
name: opp.name, name: opp.name,
chips: buyIn, chips: buyIn,

View File

@ -26,7 +26,7 @@ export function getGame(slug) {
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 }); registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 }); registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3 }); registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 7 });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });