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:
parent
24a48c13eb
commit
a05d8b6c96
|
|
@ -1,10 +1,14 @@
|
|||
import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js';
|
||||
|
||||
// Per-seat personalities indexed by seat number (1–3 for AI seats)
|
||||
// Per-seat personalities indexed by seat number (1–7 for AI seats)
|
||||
const PERSONALITIES = {
|
||||
1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 }, // Marcus
|
||||
2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 }, // Sofia
|
||||
3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 }, // third AI
|
||||
1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 },
|
||||
2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 },
|
||||
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: 0–20 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);
|
||||
|
||||
// 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) {
|
||||
// Check or bet
|
||||
|
|
|
|||
|
|
@ -21,14 +21,13 @@ const CARD_W = 100;
|
|||
const CARD_H = 140;
|
||||
const CARD_R = 8; // corner radius
|
||||
|
||||
// Seat screen positions: [human, left-AI, top-AI, right-AI]
|
||||
// portraitX/portraitY override the default world position when set.
|
||||
const SEAT_POS = [
|
||||
{ x: CX, y: 900, portraitR: 85, portraitX: CX - 210, portraitY: 900 }, // 0 — Human: portrait left of cards
|
||||
{ x: 220, y: 470, portraitR: 78 }, // 1 — AI left
|
||||
{ x: CX, y: 140, portraitR: 70, portraitX: CX - 175, portraitY: 140 }, // 2 — AI top: portrait left of cards
|
||||
{ x: 1700, y: 470, portraitR: 78 }, // 3 — AI right
|
||||
];
|
||||
// Opponents are spaced evenly around a seating ellipse so the table stays
|
||||
// balanced for any count (3–7 opponents). The human owns the bottom of the ring
|
||||
// (fixed bottom-centre). Per-game positions are computed in computeSeatLayout().
|
||||
const SEAT_ELLIPSE = { cx: CX, cy: 540, rx: 760, ry: 330 };
|
||||
// Diagonal corner seats (top/bottom on either side) are pulled this far toward
|
||||
// the table centre so they don't hug the screen edges.
|
||||
const CORNER_NUDGE = 60;
|
||||
|
||||
// Depth layers
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
// 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() {
|
||||
for (let seat = 0; seat < 4; seat++) {
|
||||
const { x, y } = SEAT_POS[seat];
|
||||
this.computeSeatLayout();
|
||||
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 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
|
||||
const nameY = isHuman ? -CARD_H / 2 - 48 : CARD_H / 2 + 28;
|
||||
const name = this.add.text(0, nameY, '—', {
|
||||
const name = this.add.text(nameX, nameY, '—', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '18px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Chip count
|
||||
const chipY = isHuman ? -CARD_H / 2 - 24 : CARD_H / 2 + 52;
|
||||
const chipTxt = this.add.text(0, chipY, '$0', {
|
||||
const chipTxt = this.add.text(chipX, chipY, '$0', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '20px',
|
||||
color: '#f0e8d0',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Bet display (shown at table edge between seat and center)
|
||||
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(0, betY, '', {
|
||||
// Bet display (shown between the seat and the table centre)
|
||||
const betTxt = this.add.text(betX, betY, '', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '18px',
|
||||
color: '#ffd700',
|
||||
|
|
@ -225,7 +300,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
this.seatContainers.push({ container, name, chipTxt, betTxt, cardObjs, dealerChip, dealerTxt });
|
||||
|
||||
// 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 portraitY = portraitYOverride ?? (isHuman
|
||||
? py - CARD_H / 2 - 48 - portraitR - 8 // above name label
|
||||
|
|
@ -345,8 +420,10 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
|
||||
if (check) {
|
||||
this.callBtn.setLabel('Check');
|
||||
this.raiseBtn.setLabel('Bet');
|
||||
} else {
|
||||
this.callBtn.setLabel(`Call $${toCall}`);
|
||||
this.raiseBtn.setLabel('Raise');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -406,7 +483,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
// ── Game flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
beginGame() {
|
||||
this.gs = createInitialState(this.opponents.slice(0, 3), this.buyIn);
|
||||
this.gs = createInitialState(this.opponents.slice(0, 7), this.buyIn);
|
||||
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).
|
||||
betZone(seat) {
|
||||
const { x, y } = SEAT_POS[seat];
|
||||
const { x, y } = this.seatPos[seat];
|
||||
const dx = CX - x;
|
||||
const dy = CY - y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
// Seats 0 and 2 sit on the vertical centre axis; shift their bet zone right
|
||||
// so chip stacks don't overlap the name/chip-count text centred at CX.
|
||||
const xOffset = (seat === 0 || seat === 2) ? 160 : 0;
|
||||
// Seats on the vertical centre axis shift their bet zone right so chip
|
||||
// stacks don't overlap the name/chip-count text centred at CX.
|
||||
const xOffset = Math.abs(x - CX) < 120 ? 160 : 0;
|
||||
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) {
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
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 tempCards = sc.cardObjs
|
||||
|
|
@ -569,8 +646,8 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
|
||||
if (tempCards.length === 0) { onComplete(); return; }
|
||||
|
||||
// Slight rotation: clockwise for left seats, counter for right
|
||||
const angle = seat === 1 ? -14 : seat === 3 ? 14 : seat === 0 ? 10 : -10;
|
||||
// Slight rotation: tilt toward the table edge based on which side the seat is on
|
||||
const angle = seat === 0 ? 10 : (sx < CX ? -14 : sx > CX ? 14 : -10);
|
||||
|
||||
this.tweens.add({
|
||||
targets: tempCards,
|
||||
|
|
@ -590,7 +667,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
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.
|
||||
animateDeal(onComplete) {
|
||||
// Deal order: one pass around the table, then a second pass (standard poker)
|
||||
const seatOrder = [1, 2, 3, 0].filter((s) => !this.gs.players[s]?.eliminated);
|
||||
// Deal order: opponents around the table (seats 1..n-1) then the human (0),
|
||||
// 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
|
||||
|
||||
let done = 0;
|
||||
|
|
@ -774,7 +856,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
|
||||
sequence.forEach((seat, idx) => {
|
||||
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 spacing = isHuman ? CARD_W + 12 : CARD_W * 0.7 + 8;
|
||||
const targetX = sx + (cardIdx === 0 ? -spacing / 2 : spacing / 2);
|
||||
|
|
@ -999,9 +1081,11 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
// ── Action badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
showActionBadge(seat, action) {
|
||||
const pos = SEAT_POS[seat];
|
||||
const pos = this.seatPos[seat];
|
||||
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 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);
|
||||
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 rowH = 155;
|
||||
const rowH = Math.min(155, 700 / players.length);
|
||||
const scale = Math.min(1, rowH / 155);
|
||||
players.forEach((player, seat) => {
|
||||
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
|
||||
|
|
@ -1096,11 +1182,12 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
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;
|
||||
|
||||
// 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)
|
||||
.setDepth(D.modal + 1).setAlpha(0);
|
||||
S.push(panel);
|
||||
|
|
@ -1108,7 +1195,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
|
||||
// Portrait backing
|
||||
const portX = 145;
|
||||
const portR = 52;
|
||||
const portR = Math.round(52 * scale);
|
||||
const backing = this.add.circle(portX, rowY, portR + 3, 0x1a1a2e)
|
||||
.setDepth(D.modal + 1).setAlpha(0);
|
||||
S.push(backing);
|
||||
|
|
@ -1152,7 +1239,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
} else {
|
||||
const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase();
|
||||
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);
|
||||
S.push(initTxt);
|
||||
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 });
|
||||
}
|
||||
|
||||
// Hole cards (0.82× scale → 82×115)
|
||||
const CW = Math.round(CARD_W * 0.82);
|
||||
const CH = Math.round(CARD_H * 0.82);
|
||||
// Hole cards (0.82× scale → 82×115, shrinks further when rows are compact)
|
||||
const cardScale = 0.82 * scale;
|
||||
const cardBaseX = 490;
|
||||
const cardOffsets = [-48, 48];
|
||||
const cardOffsets = [-48 * scale, 48 * scale];
|
||||
if (player.hand?.length === 2) {
|
||||
player.hand.forEach((card, 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) {
|
||||
this.renderCard(cont, card, false);
|
||||
this.tweens.add({ targets: cont, alpha: 0.28, duration: 350, delay });
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ export function createInitialState(opponentDefs, buyIn) {
|
|||
eliminated: false,
|
||||
hasActedThisRound: false,
|
||||
},
|
||||
...opponentDefs.slice(0, 3).map((opp, i) => ({
|
||||
...opponentDefs.slice(0, 7).map((opp, i) => ({
|
||||
seat: i + 1,
|
||||
name: opp.name,
|
||||
chips: buyIn,
|
||||
|
|
|
|||
|
|
@ -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: '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: '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: '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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue