feat: add Uno card game support

- Import and register UnoGame in the frontend client
- Add Uno to the game slug dispatch mapping in GameRoomScene
- Register Uno in the server game registry with card game configuration
This commit is contained in:
Brian Fertig 2026-05-19 19:21:12 -06:00
parent cc75ff1862
commit 3d5c49d325
7 changed files with 2030 additions and 1 deletions

View File

@ -0,0 +1,133 @@
// Uno AI — pure decision function. Mirrors the gofish/GoFishAI.js shape:
// one stateless function per decision, no scene state.
//
// Returns one of:
// { kind: 'play', cardId, chosenColor? }
// { kind: 'draw' }
// { kind: 'playDrawn' } — after a draw, when the drawn card is playable
// { kind: 'passDrawn' } — keep the drawn card, end turn
// { kind: 'chooseColor', color } — phase === 'choosingColor'
// { kind: 'challenge', doChallenge: bool } — phase === 'challengeWindow'
import { UNO_COLORS } from './UnoDeck.js';
import { legalPlays, isLegalPlay } from './UnoLogic.js';
const KIND_PRIORITY_LATE = { wild4: 9, draw2: 8, skip: 7, reverse: 6, wild: 5, number: 1 };
const KIND_PRIORITY_EARLY = { number: 5, reverse: 4, skip: 3, draw2: 2, wild: 1, wild4: 0 };
function colorTally(hand) {
const tally = { r: 0, y: 0, g: 0, b: 0 };
for (const c of hand) if (c.color !== 'w') tally[c.color] += 1;
return tally;
}
/** Pick the color the AI has the most of (ties broken by deck order). */
function bestColor(hand) {
const t = colorTally(hand);
let best = UNO_COLORS[0];
for (const c of UNO_COLORS) if (t[c] > t[best]) best = c;
return best;
}
/**
* Score a candidate play. Higher is better. Considers:
* - Punish dangerous opponents (next opponent with small hand favor action cards)
* - Save high-value cards for late game otherwise
* - Prefer playing cards whose color matches the rest of our hand
* - Never voluntarily play Wild +4 when a non-wild play exists
*/
function scoreCandidate(state, seat, card, hand) {
const N = state.players.length;
const nextSeat = ((seat + state.direction) % N + N) % N;
const nextHandSize = state.players[nextSeat].hand.length;
const dangerous = nextHandSize <= 2;
const ourHandSize = hand.length;
const earlyGame = ourHandSize >= 5;
let score;
if (dangerous) {
score = (KIND_PRIORITY_LATE[card.kind] ?? 0) * 10;
} else if (earlyGame) {
score = (KIND_PRIORITY_EARLY[card.kind] ?? 0) * 10;
} else {
// Mid-game: neutral preference for non-wild plays.
score = card.kind === 'number' ? 30 : (card.kind === 'wild4' ? 5 : 25);
}
// Bonus: playing this card leaves us with more of our dominant color in hand.
if (card.color !== 'w') {
const tally = colorTally(hand);
score += tally[card.color]; // staying in our strongest color is good
}
// Penalty: blow a Wild +4 unless it's a strong move (dangerous opponent
// OR we're stuck with this Wild +4 long-term).
if (card.kind === 'wild4' && !dangerous) score -= 25;
// Small randomization for variety.
score += Math.random() * 0.5;
return score;
}
/**
* Pick the single best action for the current phase.
*/
export function chooseAction(state, seat) {
if (state.phase === 'choosingColor') {
return { kind: 'chooseColor', color: bestColor(state.players[seat].hand) };
}
if (state.phase === 'challengeWindow') {
// We are the challenger. Without perfect information we challenge only
// when there's reasonable suspicion. Heuristics:
// - The +4 player has many cards (likely had a color match).
// - Our own hand is small (the gamble is lower-risk).
const pw = state.pendingWild4;
if (!pw) return { kind: 'challenge', doChallenge: false };
const playerHandSize = state.players[pw.playerSeat].hand.length;
const ourHandSize = state.players[seat].hand.length;
const suspicious = playerHandSize >= 4;
const lowRisk = ourHandSize <= 3;
const doChallenge = suspicious && (lowRisk || Math.random() < 0.4);
return { kind: 'challenge', doChallenge };
}
if (state.phase === 'mustPlayDrawn') {
// We just drew. Pass unless the drawn card is genuinely playable and
// playing it is better than holding it for later.
const me = state.players[seat];
const drawn = state.pendingDrawn;
if (!drawn) return { kind: 'passDrawn' };
if (!isLegalPlay(state, drawn, me.hand)) return { kind: 'passDrawn' };
// Always play numbers / actions when we can. For wilds, play them only if
// we have no other play (we already drew, so we're not blowing tempo).
return { kind: 'playDrawn' };
}
if (state.phase !== 'play') return { kind: 'draw' };
const me = state.players[seat];
const legalIds = legalPlays(state, seat);
if (legalIds.length === 0) return { kind: 'draw' };
let bestCard = null;
let bestScore = -Infinity;
for (const id of legalIds) {
const card = me.hand.find((c) => c.id === id);
if (!card) continue;
const s = scoreCandidate(state, seat, card, me.hand);
if (s > bestScore) {
bestScore = s;
bestCard = card;
}
}
if (!bestCard) return { kind: 'draw' };
const result = { kind: 'play', cardId: bestCard.id };
if (bestCard.color === 'w') {
// Pre-compute the color we'll choose so the scene can animate atomically.
// The engine still calls applyChooseColor separately.
result.chosenColor = bestColor(me.hand.filter((c) => c.id !== bestCard.id));
}
return result;
}

View File

@ -0,0 +1,82 @@
// Uno deck. 108 cards:
// - For each of 4 colors (r/y/g/b): one 0, two each of 19, two Skip,
// two Reverse, two Draw 2 = 25 × 4 = 100.
// - Plus 4 Wild and 4 Wild Draw 4 = 108.
export const UNO_COLORS = ['r', 'y', 'g', 'b']; // wild cards use 'w'
export const UNO_KINDS = ['number', 'skip', 'reverse', 'draw2', 'wild', 'wild4'];
export class UnoCard {
constructor({ color, kind, value = null }) {
this.color = color; // 'r' | 'y' | 'g' | 'b' | 'w'
this.kind = kind; // see UNO_KINDS
this.value = value; // 0..9 for kind='number', else null
this.chosenColor = null; // set on wilds after they are played
this.id = null; // assigned by buildDeck()
}
/** Mattel scoring (reserved for future multi-round play). */
get points() {
if (this.kind === 'number') return this.value;
if (this.kind === 'wild' || this.kind === 'wild4') return 50;
return 20;
}
/** Short human label used on the card face. */
get label() {
switch (this.kind) {
case 'number': return String(this.value);
case 'skip': return '⊘';
case 'reverse': return '⇄';
case 'draw2': return '+2';
case 'wild': return '★';
case 'wild4': return '+4';
default: return '';
}
}
/** True if this is a Wild or Wild Draw 4 (color === 'w'). */
get isWild() {
return this.color === 'w';
}
/**
* The color the card matches against when on top of the discard pile.
* For wilds, this is whatever color the player chose; until chosen, null.
*/
get effectiveColor() {
return this.isWild ? this.chosenColor : this.color;
}
}
export function buildDeck() {
const cards = [];
let id = 0;
const push = (spec) => {
const c = new UnoCard(spec);
c.id = id++;
cards.push(c);
};
for (const color of UNO_COLORS) {
push({ color, kind: 'number', value: 0 });
for (let v = 1; v <= 9; v++) {
push({ color, kind: 'number', value: v });
push({ color, kind: 'number', value: v });
}
for (let i = 0; i < 2; i++) push({ color, kind: 'skip' });
for (let i = 0; i < 2; i++) push({ color, kind: 'reverse' });
for (let i = 0; i < 2; i++) push({ color, kind: 'draw2' });
}
for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild' });
for (let i = 0; i < 4; i++) push({ color: 'w', kind: 'wild4' });
return cards;
}
export function cloneCard(c) {
const out = new UnoCard({ color: c.color, kind: c.kind, value: c.value });
out.id = c.id;
out.chosenColor = c.chosenColor;
return out;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,446 @@
// Uno — pure state engine. No Phaser imports.
//
// Mattel rules (official):
// - 108-card deck; 7 cards dealt to each of 2..4 players.
// - On your turn, play a card matching the top discard's color, number, or
// action; or play a Wild; or draw one card from the draw pile. If the drawn
// card is playable you may play it immediately; otherwise the turn passes.
// - Action cards: Skip (next player loses turn), Reverse (flip direction;
// in a 2-player game it acts like a Skip), Draw 2 (next player draws 2 and
// loses turn), Wild (choose color), Wild Draw 4 (choose color; next player
// draws 4 and loses turn — but may challenge first).
// - Wild Draw 4 is only legal if you have no card matching the active color.
// If challenged and the challenge succeeds, the player who played it draws
// the 4 and play continues normally; if the challenge fails, the challenger
// draws 6 instead and loses their turn.
// - No stacking, no jump-in, no 7-0 swap.
// - First player to empty their hand wins.
import { buildDeck, cloneCard, UNO_COLORS } from './UnoDeck.js';
export const HAND_DEAL = 7;
// ── PRNG (Mulberry32) — mirrors GoFishLogic for seedable shuffles. ──────────
function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, seed) {
const rand = seed === undefined ? Math.random : rng(seed);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// ── State helpers ───────────────────────────────────────────────────────────
export function cloneState(state) {
return {
players: state.players.map((p) => ({
seat: p.seat,
hand: p.hand.map(cloneCard),
})),
drawPile: state.drawPile.map(cloneCard),
discardPile: state.discardPile.map(cloneCard),
currentPlayer: state.currentPlayer,
direction: state.direction,
currentColor: state.currentColor,
phase: state.phase,
pendingWild4: state.pendingWild4 ? { ...state.pendingWild4 } : null,
pendingDrawn: state.pendingDrawn ? cloneCard(state.pendingDrawn) : null,
lastAction: state.lastAction ? { ...state.lastAction } : null,
log: state.log.map((e) => ({ ...e })),
winnerSeat: state.winnerSeat,
seed: state.seed,
turnCount: state.turnCount,
};
}
function topDiscard(state) {
return state.discardPile[state.discardPile.length - 1];
}
function nextSeat(state, fromSeat = state.currentPlayer, hops = 1) {
const N = state.players.length;
let s = fromSeat;
for (let i = 0; i < hops; i++) {
s = ((s + state.direction) % N + N) % N;
}
return s;
}
function advanceTurn(state, hops = 1) {
state.currentPlayer = nextSeat(state, state.currentPlayer, hops);
state.turnCount += 1;
}
/**
* If the draw pile is empty, take all but the top discard, reset wilds, shuffle,
* and make it the new draw pile. No-op if the draw pile still has cards.
*/
function reshuffleIfNeeded(state) {
if (state.drawPile.length > 0) return;
if (state.discardPile.length <= 1) return; // shouldn't happen mid-game
const top = state.discardPile.pop();
const recycled = state.discardPile;
for (const c of recycled) {
if (c.color === 'w') c.chosenColor = null;
}
shuffle(recycled);
state.drawPile = recycled;
state.discardPile = [top];
state.log.push({ kind: 'reshuffle', count: recycled.length });
}
function drawN(state, seat, n) {
const drawn = [];
for (let i = 0; i < n; i++) {
reshuffleIfNeeded(state);
if (state.drawPile.length === 0) break; // pathological — both piles dry
const c = state.drawPile.pop();
state.players[seat].hand.push(c);
drawn.push(c);
}
return drawn;
}
// ── Legality ────────────────────────────────────────────────────────────────
/**
* Whether `card` may be played on top of the current discard given
* state.currentColor. Wilds are always legal except Wild +4, which is only
* legal when the player has no card matching state.currentColor (other wilds
* don't block it).
*/
export function isLegalPlay(state, card, playerHand) {
if (card.color === 'w') {
if (card.kind === 'wild4') {
// Only legal when the player has no card matching the active color
// (wilds in hand don't count as a matching color).
return !playerHand.some((c) => c.color === state.currentColor);
}
return true;
}
if (card.color === state.currentColor) return true;
const top = topDiscard(state);
// Match by kind when colors differ. For number cards: same value.
if (card.kind === 'number' && top.kind === 'number' && card.value === top.value) return true;
if (card.kind !== 'number' && card.kind === top.kind) return true;
return false;
}
/** Returns the list of card ids the seat could legally play right now. */
export function legalPlays(state, seat) {
if (state.phase !== 'play') return [];
if (state.currentPlayer !== seat) return [];
const hand = state.players[seat].hand;
return hand.filter((c) => isLegalPlay(state, c, hand)).map((c) => c.id);
}
/** True if the seat could legally play any card (used before forcing a draw). */
export function hasAnyLegalPlay(state, seat) {
return legalPlays(state, seat).length > 0;
}
// ── Initial state ───────────────────────────────────────────────────────────
export function createInitialState({ playerCount = 4, seed } = {}) {
if (playerCount < 2 || playerCount > 4) {
throw new Error(`Uno supports 2..4 players, got ${playerCount}`);
}
const deck = buildDeck();
shuffle(deck, seed);
const players = [];
for (let i = 0; i < playerCount; i++) {
players.push({ seat: i, hand: deck.splice(0, HAND_DEAL) });
}
// Flip top discard, re-flipping if it's a Wild +4 (Mattel rule).
let top = deck.pop();
while (top.kind === 'wild4') {
deck.unshift(top);
shuffle(deck);
top = deck.pop();
}
const state = {
players,
drawPile: deck,
discardPile: [top],
currentPlayer: 0,
direction: 1,
currentColor: top.color === 'w' ? null : top.color,
phase: 'play',
pendingWild4: null,
pendingDrawn: null,
lastAction: null,
log: [{ kind: 'start', topCard: cloneCard(top) }],
winnerSeat: null,
seed: seed ?? null,
turnCount: 0,
};
// Apply starting-card effects (the first player has not yet acted).
if (top.kind === 'wild') {
// First player must choose a color before play begins.
state.phase = 'choosingColor';
state.lastAction = { kind: 'startingWild' };
} else if (top.kind === 'skip') {
state.log.push({ kind: 'startSkip', seat: state.currentPlayer });
advanceTurn(state, 1);
} else if (top.kind === 'reverse') {
state.direction = -1;
state.log.push({ kind: 'startReverse' });
if (playerCount === 2) {
// 2-player reverse acts as a skip — first player loses their turn.
advanceTurn(state, 1);
} else {
// Direction reversed; the "first" player becomes the one before seat 0.
state.currentPlayer = nextSeat(state, 0, 1);
}
} else if (top.kind === 'draw2') {
const drawn = drawN(state, state.currentPlayer, 2);
state.log.push({ kind: 'startDraw2', seat: state.currentPlayer, count: drawn.length });
advanceTurn(state, 1);
}
return state;
}
// ── Actions ─────────────────────────────────────────────────────────────────
/**
* Play `cardId` from `seat`'s hand. For wilds, the engine transitions to
* 'choosingColor' phase call applyChooseColor next. Returns the same state
* (unchanged) if the action is illegal.
*/
export function applyPlay(state, seat, cardId) {
if (state.phase !== 'play') return state;
if (state.currentPlayer !== seat) return state;
const player = state.players[seat];
const idx = player.hand.findIndex((c) => c.id === cardId);
if (idx === -1) return state;
const card = player.hand[idx];
if (!isLegalPlay(state, card, player.hand)) return state;
const next = cloneState(state);
const me = next.players[seat];
// Snapshot whether the player had a color match BEFORE removing the card,
// for Wild +4 challenge resolution.
const hadColorMatch = me.hand.some(
(c) => c.id !== cardId && c.color === next.currentColor,
);
const played = me.hand.splice(idx, 1)[0];
played.chosenColor = null;
next.discardPile.push(played);
next.lastAction = { kind: 'play', seat, card: cloneCard(played) };
next.log.push({ kind: 'play', seat, cardId: played.id });
// Win check — playing your last card wins immediately.
if (me.hand.length === 0) {
next.winnerSeat = seat;
next.phase = 'gameOver';
next.log.push({ kind: 'win', seat });
return next;
}
// Resolve effects.
if (played.kind === 'wild') {
next.phase = 'choosingColor';
return next;
}
if (played.kind === 'wild4') {
next.phase = 'choosingColor';
next.pendingWild4 = {
playerSeat: seat,
challengerSeat: nextSeat(next, seat, 1),
hadColorMatch,
};
return next;
}
if (played.kind === 'skip') {
next.currentColor = played.color;
const skipped = nextSeat(next, seat, 1);
next.log.push({ kind: 'skip', seat: skipped });
advanceTurn(next, 2);
return next;
}
if (played.kind === 'reverse') {
next.currentColor = played.color;
next.direction = -next.direction;
next.log.push({ kind: 'reverse' });
if (next.players.length === 2) {
// 2-player Reverse acts as Skip — same player goes again. With only two
// seats, flipping direction alone still hands the turn to the opponent,
// so we advance by 2 to wrap back to the same seat.
advanceTurn(next, 2);
} else {
advanceTurn(next, 1);
}
return next;
}
if (played.kind === 'draw2') {
next.currentColor = played.color;
const target = nextSeat(next, seat, 1);
const drawn = drawN(next, target, 2);
next.log.push({ kind: 'draw2', seat: target, count: drawn.length });
advanceTurn(next, 2);
return next;
}
// Number card.
next.currentColor = played.color;
advanceTurn(next, 1);
return next;
}
/**
* Choose the color after playing a Wild or Wild +4 (or after the starting Wild
* top-discard). Returns the same state if not currently in 'choosingColor'.
*/
export function applyChooseColor(state, color) {
if (state.phase !== 'choosingColor') return state;
if (!UNO_COLORS.includes(color)) return state;
const next = cloneState(state);
next.currentColor = color;
const top = topDiscard(next);
if (top.color === 'w') top.chosenColor = color;
next.log.push({ kind: 'chooseColor', color });
// Resolve the pending follow-up.
if (next.pendingWild4) {
next.phase = 'challengeWindow';
next.lastAction = { kind: 'wild4Pending', color };
return next;
}
// After a regular Wild: advance turn.
// After a starting Wild (top discard was wild at game start): the same seat 0
// gets the first play — do NOT advance.
const wasStartingWild = next.lastAction?.kind === 'startingWild';
next.phase = 'play';
if (!wasStartingWild) advanceTurn(next, 1);
next.lastAction = { kind: 'chooseColor', color };
return next;
}
/**
* The challenger either accepts the Wild +4 or challenges it. Resolves the
* draw and turn order accordingly.
*/
export function applyChallengeWild4(state, doChallenge) {
if (state.phase !== 'challengeWindow') return state;
const next = cloneState(state);
const pw = next.pendingWild4;
if (!pw) return state;
if (!doChallenge) {
// Accept: challenger draws 4 and is skipped.
const drawn = drawN(next, pw.challengerSeat, 4);
next.log.push({ kind: 'wild4Accept', seat: pw.challengerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'accept',
drawSeat: pw.challengerSeat,
drawCount: drawn.length,
};
// Move past the player who played the +4 and past the now-skipped challenger.
next.currentPlayer = pw.playerSeat;
advanceTurn(next, 2);
next.pendingWild4 = null;
next.phase = 'play';
return next;
}
if (pw.hadColorMatch) {
// Successful challenge: WD4 player draws 4, play continues normally.
const drawn = drawN(next, pw.playerSeat, 4);
next.log.push({ kind: 'wild4ChallengeWin', seat: pw.playerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'challengeWin',
drawSeat: pw.playerSeat,
drawCount: drawn.length,
};
next.currentPlayer = pw.challengerSeat;
next.turnCount += 1;
} else {
// Failed challenge: challenger draws 6 and is skipped.
const drawn = drawN(next, pw.challengerSeat, 6);
next.log.push({ kind: 'wild4ChallengeLose', seat: pw.challengerSeat, count: drawn.length });
next.lastAction = {
kind: 'wild4Resolve',
result: 'challengeLose',
drawSeat: pw.challengerSeat,
drawCount: drawn.length,
};
next.currentPlayer = pw.playerSeat;
advanceTurn(next, 2);
}
next.pendingWild4 = null;
next.phase = 'play';
return next;
}
/**
* Draw a single card from the draw pile. If the drawn card is playable the
* state transitions to 'mustPlayDrawn' and the UI prompts the player to keep
* or play it. Otherwise the turn advances automatically.
*/
export function applyDraw(state, seat) {
if (state.phase !== 'play') return state;
if (state.currentPlayer !== seat) return state;
const next = cloneState(state);
const drawn = drawN(next, seat, 1);
if (drawn.length === 0) {
// Both piles dry — pathological. Just advance the turn.
advanceTurn(next, 1);
return next;
}
const card = drawn[0];
next.lastAction = { kind: 'draw', seat, card: cloneCard(card) };
next.log.push({ kind: 'draw', seat, count: 1 });
if (isLegalPlay(next, card, next.players[seat].hand)) {
next.phase = 'mustPlayDrawn';
next.pendingDrawn = cloneCard(card);
return next;
}
advanceTurn(next, 1);
return next;
}
/**
* After drawing, the player elects to play the drawn card. Internally this is
* just applyPlay (with the same card id), but it transitions from
* 'mustPlayDrawn' back to 'play' first.
*/
export function applyPlayDrawn(state, seat) {
if (state.phase !== 'mustPlayDrawn') return state;
if (state.currentPlayer !== seat) return state;
if (!state.pendingDrawn) return state;
const cardId = state.pendingDrawn.id;
const intermediate = cloneState(state);
intermediate.phase = 'play';
intermediate.pendingDrawn = null;
return applyPlay(intermediate, seat, cardId);
}
/** After drawing a playable card, elect to keep it and pass. */
export function applyPassAfterDraw(state, seat) {
if (state.phase !== 'mustPlayDrawn') return state;
if (state.currentPlayer !== seat) return state;
const next = cloneState(state);
next.phase = 'play';
next.pendingDrawn = null;
advanceTurn(next, 1);
return next;
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}

View File

@ -20,6 +20,7 @@ import SkipBoGame from './games/skipbo/SkipBoGame.js';
import Phase10Game from './games/phase10/Phase10Game.js';
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
import GoFishGame from './games/gofish/GoFishGame.js';
import UnoGame from './games/uno/UnoGame.js';
const config = {
type: Phaser.AUTO,
@ -53,6 +54,7 @@ const config = {
Phase10Game,
ChineseCheckersGame,
GoFishGame,
UnoGame,
],
};

View File

@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -34,3 +34,4 @@ registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: tru
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });