feat: add Monopoly board game

- Add complete Monopoly implementation with pure state engine (MonopolyLogic.js),
  static data (MonopolyData.js), AI (MonopolyAI.js), and Phaser scene (MonopolyGame.js)
- Implement full game rules: property buying, auctions, building houses/hotels,
  mortgages, jail, chance/community chest cards, rent calculation, bankruptcy
- Add 5-level AI with configurable greed, blunder rate, and thinking delay
- Add spritesheet loading for monopoly pawns and cards with graceful fallbacks
- Register game in registry, main.js, and game room scene dispatcher
- Add spritesheet creation guide (sprites.md) and update game-icons.png
This commit is contained in:
Brian Fertig 2026-06-07 14:37:06 -06:00
parent 27635d166f
commit 10ac18ab6e
12 changed files with 2365 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,82 @@
// Monopoly — AI decisions. Greedy heuristic, skill 15.
import {
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS,
} from './MonopolyData.js';
import {
ownsGroup, canBuildHouse, canBuildHotel, netWorth,
} from './MonopolyLogic.js';
const PROFILES = {
1: { reserve:0, maxBidMult:0.70, noise:50, blunder:0.35, delay:[900,1500] },
2: { reserve:100, maxBidMult:0.80, noise:30, blunder:0.20, delay:[750,1300] },
3: { reserve:200, maxBidMult:0.90, noise:15, blunder:0.10, delay:[600,1100] },
4: { reserve:300, maxBidMult:1.00, noise:5, blunder:0.03, delay:[500,950] },
5: { reserve:400, maxBidMult:1.10, noise:0, blunder:0.00, delay:[400,800] },
};
function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
function noise(n) { return (Math.random() - 0.5) * n; }
export function nextThinkDelay(skill) {
const [lo, hi] = PROFILES[skill]?.delay ?? [600, 1100];
return rnd(lo, hi);
}
// ── Buy decision ───────────────────────────────────────────────────────────────
export function chooseBuy(state, seat, skill) {
const prof = PROFILES[skill] ?? PROFILES[3];
if (Math.random() < prof.blunder) return Math.random() < 0.5;
const { spaceIdx } = state.pendingBuy;
const sp = SPACES[spaceIdx];
const p = state.players[seat];
const reserve = prof.reserve + noise(prof.noise);
return p.cash - sp.price >= reserve;
}
// ── Auction bid ───────────────────────────────────────────────────────────────
export function chooseBid(state, seat, skill) {
const prof = PROFILES[skill] ?? PROFILES[3];
const auc = state.pendingAuction;
const sp = SPACES[auc.spaceIdx];
const p = state.players[seat];
const maxBid = Math.min(p.cash * 0.7, sp.price * prof.maxBidMult + noise(prof.noise));
if (maxBid <= auc.highBid) return null; // pass
return Math.max(auc.highBid + 1, Math.floor(maxBid));
}
// ── Jail decision ─────────────────────────────────────────────────────────────
export function chooseJailAction(state, seat, skill) {
const p = state.players[seat];
if (p.getOutOfJailFree > 0) return 'card';
if (skill >= 3 && p.cash >= 50) {
// Higher skill: pay to get back in action
return 'pay';
}
return 'roll';
}
// ── Build decisions ────────────────────────────────────────────────────────────
// Returns { action: 'house'|'hotel', spaceIdx } or null
export function chooseBuild(state, seat, skill) {
const prof = PROFILES[skill] ?? PROFILES[3];
const p = state.players[seat];
// Try to build hotels first on complete sets
for (const idx of PURCHASABLE) {
if (!canBuildHotel(state, seat, idx)) continue;
const sp = SPACES[idx];
if (p.cash - sp.houseCost >= prof.reserve) {
return { action: 'hotel', spaceIdx: idx };
}
}
// Build houses on monopolies
for (const idx of PURCHASABLE) {
if (!canBuildHouse(state, seat, idx)) continue;
const sp = SPACES[idx];
if (p.cash - sp.houseCost >= prof.reserve) {
return { action: 'house', spaceIdx: idx };
}
}
return null;
}

View File

@ -0,0 +1,165 @@
// Monopoly — static data. No Phaser, no state.
export const BOARD_SIZE = 840;
export const CORNER_SIZE = 105;
export const SPACE_W = 70;
export const BAND_H = 18;
export const GROUP_COLORS = {
brown: 0x9B5524,
lightblue:0x00B3E8,
pink: 0xD63384,
orange: 0xE77A2C,
red: 0xDC3545,
yellow: 0xE8C12C,
green: 0x1F8C3B,
darkblue: 0x003580,
};
export const GROUP_HEX = {
brown: '#9B5524',
lightblue:'#00B3E8',
pink: '#D63384',
orange: '#E77A2C',
red: '#DC3545',
yellow: '#E8C12C',
green: '#1F8C3B',
darkblue: '#003580',
};
// 40 spaces, index 039, clockwise starting from Go.
// rent[]: [base, monopoly, 1house, 2h, 3h, 4h, hotel]
export const SPACES = [
{ type:'go', name:'GO' },
{ type:'property', name:'Mediterranean Ave', group:'brown', price:60, rent:[2,4,10,30,90,160,250], houseCost:50, mortgage:30 },
{ type:'community_chest', name:'Community Chest' },
{ type:'property', name:'Baltic Ave', group:'brown', price:60, rent:[4,8,20,60,180,320,450], houseCost:50, mortgage:30 },
{ type:'tax', name:'Income Tax', amount:200 },
{ type:'railroad', name:'Reading Railroad', price:200, mortgage:100 },
{ type:'property', name:'Oriental Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 },
{ type:'chance', name:'Chance' },
{ type:'property', name:'Vermont Ave', group:'lightblue',price:100, rent:[6,12,30,90,270,400,550], houseCost:50, mortgage:50 },
{ type:'property', name:'Connecticut Ave', group:'lightblue',price:120, rent:[8,16,40,100,300,450,600], houseCost:50, mortgage:60 },
{ type:'jail', name:'Jail / Just Visiting' },
{ type:'property', name:'St. Charles Place', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 },
{ type:'utility', name:'Electric Company', price:150, mortgage:75 },
{ type:'property', name:'States Ave', group:'pink', price:140, rent:[10,20,50,150,450,625,750], houseCost:100, mortgage:70 },
{ type:'property', name:'Virginia Ave', group:'pink', price:160, rent:[12,24,60,180,500,700,900], houseCost:100, mortgage:80 },
{ type:'railroad', name:'Pennsylvania Railroad',price:200, mortgage:100 },
{ type:'property', name:'St. James Place', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 },
{ type:'community_chest', name:'Community Chest' },
{ type:'property', name:'Tennessee Ave', group:'orange', price:180, rent:[14,28,70,200,550,750,950], houseCost:100, mortgage:90 },
{ type:'property', name:'New York Ave', group:'orange', price:200, rent:[16,32,80,220,600,800,1000], houseCost:100, mortgage:100 },
{ type:'freeparking', name:'Free Parking' },
{ type:'property', name:'Kentucky Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 },
{ type:'chance', name:'Chance' },
{ type:'property', name:'Indiana Ave', group:'red', price:220, rent:[18,36,90,250,700,875,1050], houseCost:150, mortgage:110 },
{ type:'property', name:'Illinois Ave', group:'red', price:240, rent:[20,40,100,300,750,925,1100], houseCost:150, mortgage:120 },
{ type:'railroad', name:'B&O Railroad', price:200, mortgage:100 },
{ type:'property', name:'Atlantic Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 },
{ type:'property', name:'Ventnor Ave', group:'yellow', price:260, rent:[22,44,110,330,800,975,1150], houseCost:150, mortgage:130 },
{ type:'utility', name:'Water Works', price:150, mortgage:75 },
{ type:'property', name:'Marvin Gardens', group:'yellow', price:280, rent:[24,48,120,360,850,1025,1200], houseCost:150, mortgage:140 },
{ type:'gotojail', name:'Go To Jail' },
{ type:'property', name:'Pacific Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 },
{ type:'property', name:'North Carolina Ave', group:'green', price:300, rent:[26,52,130,390,900,1100,1275], houseCost:200, mortgage:150 },
{ type:'community_chest', name:'Community Chest' },
{ type:'property', name:'Pennsylvania Ave', group:'green', price:320, rent:[28,56,150,450,1000,1200,1400], houseCost:200, mortgage:160 },
{ type:'railroad', name:'Short Line Railroad', price:200, mortgage:100 },
{ type:'chance', name:'Chance' },
{ type:'property', name:'Park Place', group:'darkblue',price:350, rent:[35,70,175,500,1100,1300,1500], houseCost:200, mortgage:175 },
{ type:'tax', name:'Luxury Tax', amount:100 },
{ type:'property', name:'Boardwalk', group:'darkblue',price:400, rent:[50,100,200,600,1400,1700,2000], houseCost:200, mortgage:200 },
];
export const RAILROADS = [5, 15, 25, 35];
export const UTILITIES = [12, 28];
export const PURCHASABLE = SPACES.reduce((a,sp,i) =>
(sp.type==='property'||sp.type==='railroad'||sp.type==='utility') ? [...a,i] : a, []);
export const GROUPS = {
brown: [1, 3],
lightblue:[6, 8, 9],
pink: [11, 13, 14],
orange: [16, 18, 19],
red: [21, 23, 24],
yellow: [26, 27, 29],
green: [31, 32, 34],
darkblue: [37, 39],
};
export const PLAYER_COLORS = [0xE53935, 0x1565C0, 0x2E7D32, 0xF57F17];
export const PLAYER_COLOR_HEX = ['#E53935', '#1565C0', '#2E7D32', '#F57F17'];
// Chance cards (16)
export const CHANCE_CARDS = [
{ text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 },
{ text:'Advance to Illinois Ave.\nIf you pass GO, collect $200.', effect:'advance_to', target:24 },
{ text:'Advance to St. Charles Place.\nIf you pass GO, collect $200.', effect:'advance_to', target:11 },
{ text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true },
{ text:'Advance to the nearest Railroad.\nIf unowned, you may buy it.\nIf owned, pay double rent.', effect:'nearest_railroad', doubleRent:true },
{ text:'Advance to the nearest Utility.\nIf unowned, you may buy it.\nIf owned, pay 10× your dice.', effect:'nearest_utility' },
{ text:'Bank pays you a dividend of $50.', effect:'collect', amount:50 },
{ text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' },
{ text:'Go back 3 spaces.', effect:'back3' },
{ text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' },
{ text:'Make general repairs on all your property.\n$25 per house · $100 per hotel.', effect:'repairs', house:25, hotel:100 },
{ text:'Pay a poor tax of $15.', effect:'pay', amount:15 },
{ text:'Take a trip to Reading Railroad.\nIf you pass GO, collect $200.', effect:'advance_to', target:5 },
{ text:'Advance to Boardwalk.', effect:'advance_to', target:39 },
{ text:'You have been elected Chairman of the Board.\nPay each player $50.', effect:'pay_each', amount:50 },
{ text:'Your building and loan matures.\nCollect $150.', effect:'collect', amount:150 },
];
// Community Chest cards (16)
export const CC_CARDS = [
{ text:'Advance to GO.\nCollect $200.', effect:'advance_to', target:0 },
{ text:'Bank error in your favor.\nCollect $200.', effect:'collect', amount:200 },
{ text:"Doctor's fee.\nPay $50.", effect:'pay', amount:50 },
{ text:'From sale of stock you get $50.', effect:'collect', amount:50 },
{ text:'Get Out of Jail Free.\nKeep this card until needed.', effect:'goojf' },
{ text:'Go directly to Jail.\nDo not pass GO. Do not collect $200.', effect:'go_to_jail' },
{ text:'Grand Opera Night.\nCollect $50 from every player.', effect:'collect_each', amount:50 },
{ text:'Holiday fund matures.\nCollect $100.', effect:'collect', amount:100 },
{ text:'Income tax refund.\nCollect $20.', effect:'collect', amount:20 },
{ text:'It is your birthday!\nCollect $10 from every player.', effect:'collect_each', amount:10 },
{ text:'Life insurance matures.\nCollect $100.', effect:'collect', amount:100 },
{ text:'Pay hospital fees of $100.', effect:'pay', amount:100 },
{ text:'Pay school fees of $150.', effect:'pay', amount:150 },
{ text:'Receive $25 consultancy fee.', effect:'collect', amount:25 },
{ text:'You are assessed for street repairs.\n$40 per house · $115 per hotel.', effect:'repairs', house:40, hotel:115 },
{ text:'You have won second prize in a beauty contest.\nCollect $10.', effect:'collect', amount:10 },
];
// Board-relative space geometry (add boardLeft, boardTop in scene)
export function spaceGeometry(idx) {
const C = CORNER_SIZE, W = SPACE_W, S = BOARD_SIZE;
if (idx === 0) return { x:S-C, y:S-C, w:C, h:C, isCorner:true };
if (idx === 10) return { x:0, y:S-C, w:C, h:C, isCorner:true };
if (idx === 20) return { x:0, y:0, w:C, h:C, isCorner:true };
if (idx === 30) return { x:S-C, y:0, w:C, h:C, isCorner:true };
if (idx >= 1 && idx <= 9) return { x:S-C-W*idx, y:S-C, w:W, h:C, bandEdge:'top', rotation:0 };
if (idx >= 11 && idx <= 19) return { x:0, y:S-C-W*(idx-10), w:C, h:W, bandEdge:'right', rotation:-Math.PI/2 };
if (idx >= 21 && idx <= 29) return { x:C+W*(idx-21), y:0, w:W, h:C, bandEdge:'bottom', rotation:Math.PI };
if (idx >= 31 && idx <= 39) return { x:S-C, y:C+W*(idx-31),w:C, h:W, bandEdge:'left', rotation:Math.PI/2 };
}
export function spaceCenter(idx) {
const g = spaceGeometry(idx);
return { x: g.x + g.w / 2, y: g.y + g.h / 2 };
}
export function nearestRailroad(pos) {
for (const r of RAILROADS) if (r > pos) return r;
return RAILROADS[0];
}
export function nearestUtility(pos) {
for (const u of UTILITIES) if (u > pos) return u;
return UTILITIES[0];
}
// Pawn spritesheet: frame = seat index (0-3), 80×80 px cells
export const PAWN_FRAME = (seat) => seat;
// Card spritesheet: frame 0 = Chance, frame 1 = Community Chest, 200×300 px cells
export const CARD_FRAME = { chance: 0, community_chest: 1 };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,721 @@
// Monopoly — pure state engine. No Phaser, no rendering.
// All mutators deep-clone and return a fresh state.
import {
SPACES, RAILROADS, UTILITIES, PURCHASABLE, GROUPS, GROUP_COLORS,
CHANCE_CARDS, CC_CARDS, nearestRailroad, nearestUtility,
} from './MonopolyData.js';
// ── RNG ───────────────────────────────────────────────────────────────────────
function rngFrom(seed) {
let a = (seed >>> 0) || 1;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const clone = (s) => JSON.parse(JSON.stringify(s));
// ── State ─────────────────────────────────────────────────────────────────────
export function createInitialState({ playerCount, names, seed = Date.now() }) {
const rng = rngFrom(seed);
const board = {};
for (const idx of PURCHASABLE) board[idx] = { owner: null, houses: 0, hotel: false, mortgaged: false };
return {
playerCount,
seed,
phase: 'preroll',
current: 0,
doublesCount: 0,
diceRoll: null,
houseSupply: 32,
hotelSupply: 12,
players: Array.from({ length: playerCount }, (_, seat) => ({
seat,
name: names[seat] ?? `Player ${seat + 1}`,
cash: 1500,
position: 0,
jailed: false,
jailTurns: 0,
getOutOfJailFree: 0,
bankrupt: false,
active: true,
})),
board,
chanceOrder: shuffle(CHANCE_CARDS.map((_, i) => i), rng),
chanceIdx: 0,
ccOrder: shuffle(CC_CARDS.map((_, i) => i), rng),
ccIdx: 0,
pendingCard: null,
pendingBuy: null,
pendingAuction: null,
winner: null,
log: [],
};
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function log(s, msg) { s.log = [msg, ...s.log.slice(0, 49)]; }
export function ownsGroup(state, seat, group) {
const indices = GROUPS[group];
if (!indices) return false;
return indices.every(i => state.board[i]?.owner === seat);
}
function railroadsOwned(state, seat) {
return RAILROADS.filter(i => state.board[i]?.owner === seat).length;
}
function utilitiesOwned(state, seat) {
return UTILITIES.filter(i => state.board[i]?.owner === seat).length;
}
export function calculateRent(state, spaceIdx, diceTotal, forceUtilityMult = null) {
const sp = SPACES[spaceIdx];
const own = state.board[spaceIdx];
if (!own || own.owner === null || own.mortgaged) return 0;
const { owner, houses, hotel } = own;
if (sp.type === 'property') {
let idx;
if (hotel) idx = 6;
else if (houses>0) idx = houses + 1;
else if (ownsGroup(state, owner, sp.group)) idx = 1;
else idx = 0;
return sp.rent[idx];
}
if (sp.type === 'railroad') {
const cnt = railroadsOwned(state, owner);
return 25 * Math.pow(2, cnt - 1);
}
if (sp.type === 'utility') {
const mult = forceUtilityMult ?? (utilitiesOwned(state, owner) === 2 ? 10 : 4);
return mult * diceTotal;
}
return 0;
}
export function netWorth(state, seat) {
const p = state.players[seat];
let w = p.cash;
for (const idx of PURCHASABLE) {
const own = state.board[idx];
if (own?.owner !== seat) continue;
const sp = SPACES[idx];
w += sp.mortgage ?? 0; // half price
if (!own.mortgaged) {
if (sp.type === 'property') {
w += own.houses * (sp.houseCost / 2);
if (own.hotel) w += sp.houseCost * 2; // hotel = 4 houses → sell back half
}
}
}
return w;
}
// Auto-liquidate houses and mortgage properties to cover a debt.
// Returns new state; sets player bankrupt if still can't pay.
function liquidate(state, seat, creditorSeat) {
const s = clone(state);
const p = s.players[seat];
// Sell houses first (even selling), then hotels
let progress = true;
while (p.cash < 0 && progress) {
progress = false;
for (const idx of PURCHASABLE) {
if (p.cash >= 0) break;
const own = s.board[idx];
if (own?.owner !== seat) continue;
const sp = SPACES[idx];
if (own.hotel) {
own.hotel = false;
own.houses = 4; // hotels become 4 houses
p.cash += sp.houseCost; // sell hotel back at half
s.hotelSupply++;
progress = true;
}
}
for (const idx of PURCHASABLE) {
if (p.cash >= 0) break;
const own = s.board[idx];
if (own?.owner !== seat || own.houses === 0) continue;
const sp = SPACES[idx];
own.houses--;
p.cash += sp.houseCost / 2;
s.houseSupply++;
progress = true;
}
// Mortgage un-mortgaged properties
for (const idx of PURCHASABLE) {
if (p.cash >= 0) break;
const own = s.board[idx];
if (own?.owner !== seat || own.mortgaged || own.houses > 0 || own.hotel) continue;
const sp = SPACES[idx];
own.mortgaged = true;
p.cash += sp.mortgage;
progress = true;
}
}
if (p.cash < 0) {
// Bankrupt: transfer remaining properties to creditor (or bank if creditorSeat null)
for (const idx of PURCHASABLE) {
const own = s.board[idx];
if (own?.owner !== seat) continue;
if (creditorSeat !== null && creditorSeat !== undefined) {
own.owner = creditorSeat;
own.mortgaged = true; // creditor inherits mortgaged
own.houses = 0; own.hotel = false;
} else {
own.owner = null;
own.houses = 0; own.hotel = false;
own.mortgaged = false;
}
}
if (creditorSeat !== null && creditorSeat !== undefined) {
s.players[creditorSeat].cash += Math.max(0, p.cash + p.cash); // whatever was left (≤0, so skip)
}
p.cash = 0;
p.bankrupt = true;
p.active = false;
log(s, `${p.name} is bankrupt and out of the game.`);
}
return s;
}
function payTo(state, payerSeat, receiverSeat, amount) {
const s = clone(state);
s.players[payerSeat].cash -= amount;
if (receiverSeat !== null && receiverSeat !== undefined) {
s.players[receiverSeat].cash += amount;
}
if (s.players[payerSeat].cash < 0) {
return liquidate(s, payerSeat, receiverSeat);
}
return s;
}
export function checkGameOver(state) {
const s = clone(state);
const active = s.players.filter(p => p.active);
if (active.length <= 1) {
s.phase = 'gameover';
s.winner = active[0]?.seat ?? null;
if (s.winner !== null) log(s, `${s.players[s.winner].name} wins!`);
}
return s;
}
// ── Roll & Move ───────────────────────────────────────────────────────────────
export function rollDice(state, seat, d1, d2) {
const s = clone(state);
s.diceRoll = [d1, d2];
const p = s.players[seat];
const isDoubles = d1 === d2;
if (p.jailed) {
if (isDoubles) {
p.jailed = false;
p.jailTurns = 0;
const newPos = (p.position + d1 + d2) % 40;
p.position = newPos;
// Don't get another turn from jail doubles
s.doublesCount = 0;
log(s, `${p.name} rolls doubles and escapes jail!`);
} else {
p.jailTurns++;
if (p.jailTurns >= 3) {
p.cash -= 50;
if (p.cash < 0) { const ls = liquidate(s, seat, null); Object.assign(s, ls); }
p.jailed = false;
p.jailTurns = 0;
const newPos = (p.position + d1 + d2) % 40;
s.players[seat].position = newPos;
log(s, `${p.name} pays $50 to leave jail after 3 turns.`);
} else {
log(s, `${p.name} fails to roll doubles in jail (turn ${p.jailTurns}).`);
s.phase = 'endturn';
return s;
}
}
} else {
if (isDoubles) {
s.doublesCount++;
if (s.doublesCount >= 3) {
// Three doubles: go to jail
p.position = 10;
p.jailed = true;
p.jailTurns = 0;
s.doublesCount = 0;
log(s, `${p.name} rolls 3 doubles in a row — Go to Jail!`);
s.phase = 'endturn';
return s;
}
} else {
s.doublesCount = 0;
}
const oldPos = p.position;
const newPos = (oldPos + d1 + d2) % 40;
p.position = newPos;
if (newPos < oldPos || (oldPos === 0 && newPos > 0)) {
p.cash += 200;
log(s, `${p.name} passes GO, collects $200.`);
}
}
return resolveSpace(s, seat);
}
// ── Space Resolution ──────────────────────────────────────────────────────────
export function resolveSpace(state, seat) {
const s = clone(state);
const p = s.players[seat];
const spIdx = p.position;
const sp = SPACES[spIdx];
switch (sp.type) {
case 'go':
s.phase = 'endturn';
break;
case 'property':
case 'railroad':
case 'utility': {
const own = s.board[spIdx];
if (!own || own.owner === null) {
s.pendingBuy = { spaceIdx: spIdx };
s.phase = 'buy';
} else if (own.owner === seat) {
log(s, `${p.name} owns this property.`);
s.phase = 'endturn';
} else if (own.mortgaged) {
log(s, `${SPACES[spIdx].name} is mortgaged — no rent.`);
s.phase = 'endturn';
} else {
const dice = s.diceRoll[0] + s.diceRoll[1];
const rent = calculateRent(s, spIdx, dice);
const result = payTo(s, seat, own.owner, rent);
Object.assign(s, result);
log(s, `${p.name} pays $${rent} rent to ${s.players[own.owner].name}.`);
s.phase = 'endturn';
}
break;
}
case 'tax': {
const result = payTo(s, seat, null, sp.amount);
Object.assign(s, result);
log(s, `${p.name} pays ${sp.name} ($${sp.amount}).`);
s.phase = 'endturn';
break;
}
case 'chance': {
const cardIdx = s.chanceOrder[s.chanceIdx % s.chanceOrder.length];
s.chanceIdx++;
const card = CHANCE_CARDS[cardIdx];
s.pendingCard = { cardType: 'chance', cardIdx, text: card.text, effect: card };
s.phase = 'card';
break;
}
case 'community_chest': {
const cardIdx = s.ccOrder[s.ccIdx % s.ccOrder.length];
s.ccIdx++;
const card = CC_CARDS[cardIdx];
s.pendingCard = { cardType: 'community_chest', cardIdx, text: card.text, effect: card };
s.phase = 'card';
break;
}
case 'gotojail':
p.position = 10;
p.jailed = true;
p.jailTurns = 0;
s.doublesCount = 0;
log(s, `${p.name} is sent to Jail!`);
s.phase = 'endturn';
break;
case 'jail':
case 'freeparking':
default:
s.phase = 'endturn';
break;
}
return s;
}
// ── Card Effects ──────────────────────────────────────────────────────────────
export function applyCardEffect(state, seat) {
let s = clone(state);
const p = s.players[seat];
const card = s.pendingCard?.effect;
if (!card) { s.pendingCard = null; s.phase = 'endturn'; return s; }
switch (card.effect) {
case 'advance_to': {
const target = card.target;
const old = p.position;
p.position = target;
if (target <= old && target !== old) {
p.cash += 200;
log(s, `${p.name} passes GO, collects $200.`);
}
s.pendingCard = null;
s = resolveSpace(s, seat);
break;
}
case 'nearest_railroad': {
const target = nearestRailroad(p.position);
const old = p.position;
p.position = target;
if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); }
s.pendingCard = null;
const own = s.board[target];
if (!own || own.owner === null) {
s.pendingBuy = { spaceIdx: target };
s.phase = 'buy';
} else if (own.owner !== seat && !own.mortgaged) {
const cnt = railroadsOwned(s, own.owner);
const rent = 25 * Math.pow(2, cnt - 1) * (card.doubleRent ? 2 : 1);
const result = payTo(s, seat, own.owner, rent);
Object.assign(s, result);
log(s, `${p.name} pays $${rent} (double) railroad rent.`);
s.phase = 'endturn';
} else {
s.phase = 'endturn';
}
break;
}
case 'nearest_utility': {
const target = nearestUtility(p.position);
const old = p.position;
p.position = target;
if (target <= old) { p.cash += 200; log(s, `${p.name} passes GO, collects $200.`); }
s.pendingCard = null;
const own = s.board[target];
if (!own || own.owner === null) {
s.pendingBuy = { spaceIdx: target };
s.phase = 'buy';
} else if (own.owner !== seat && !own.mortgaged) {
const dice = s.diceRoll[0] + s.diceRoll[1];
const rent = 10 * dice;
const result = payTo(s, seat, own.owner, rent);
Object.assign(s, result);
log(s, `${p.name} pays $${rent} (10× dice) utility rent.`);
s.phase = 'endturn';
} else {
s.phase = 'endturn';
}
break;
}
case 'collect':
p.cash += card.amount;
log(s, `${p.name} collects $${card.amount}.`);
s.pendingCard = null;
s.phase = 'endturn';
break;
case 'pay': {
const result = payTo(s, seat, null, card.amount);
Object.assign(s, result);
log(s, `${p.name} pays $${card.amount}.`);
s.pendingCard = null;
s.phase = 'endturn';
break;
}
case 'goojf':
p.getOutOfJailFree++;
log(s, `${p.name} receives a Get Out of Jail Free card.`);
s.pendingCard = null;
s.phase = 'endturn';
break;
case 'go_to_jail':
p.position = 10;
p.jailed = true;
p.jailTurns = 0;
s.doublesCount = 0;
log(s, `${p.name} goes to Jail!`);
s.pendingCard = null;
s.phase = 'endturn';
break;
case 'back3': {
p.position = (p.position - 3 + 40) % 40;
s.pendingCard = null;
s = resolveSpace(s, seat);
// Don't award Go on back3
break;
}
case 'repairs': {
let total = 0;
for (const idx of PURCHASABLE) {
const own = s.board[idx];
if (own?.owner !== seat) continue;
total += own.houses * card.house;
if (own.hotel) total += card.hotel;
}
if (total > 0) {
const result = payTo(s, seat, null, total);
Object.assign(s, result);
log(s, `${p.name} pays $${total} for repairs.`);
}
s.pendingCard = null;
s.phase = 'endturn';
break;
}
case 'pay_each': {
let paid = 0;
for (const other of s.players) {
if (!other.active || other.seat === seat) continue;
const amount = Math.min(card.amount, s.players[seat].cash);
s.players[seat].cash -= amount;
other.cash += amount;
paid += amount;
}
log(s, `${p.name} pays $${card.amount} to each player.`);
s.pendingCard = null;
s.phase = 'endturn';
break;
}
case 'collect_each': {
for (const other of s.players) {
if (!other.active || other.seat === seat) continue;
const amount = Math.min(card.amount, other.cash);
other.cash -= amount;
s.players[seat].cash += amount;
}
log(s, `${p.name} collects $${card.amount} from each player.`);
s.pendingCard = null;
s.phase = 'endturn';
break;
}
default:
s.pendingCard = null;
s.phase = 'endturn';
}
return s;
}
// ── Property Purchase ─────────────────────────────────────────────────────────
export function buyProperty(state, seat) {
const s = clone(state);
const { spaceIdx } = s.pendingBuy;
const sp = SPACES[spaceIdx];
s.players[seat].cash -= sp.price;
s.board[spaceIdx].owner = seat;
log(s, `${s.players[seat].name} buys ${sp.name} for $${sp.price}.`);
s.pendingBuy = null;
s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn';
return checkGameOver(s);
}
export function declineProperty(state, seat) {
return startAuction(state, state.pendingBuy.spaceIdx);
}
// ── Auction ───────────────────────────────────────────────────────────────────
function startAuction(state, spaceIdx) {
const s = clone(state);
const bidOrder = [];
for (let i = 0; i < s.playerCount; i++) {
const seat = (s.current + i) % s.playerCount;
if (s.players[seat].active) bidOrder.push(seat);
}
s.pendingBuy = null;
s.pendingAuction = { spaceIdx, bidOrder, currentBidderIdx: 0, bids: {}, highBid: 0, highBidder: null };
s.phase = 'auction';
return s;
}
export function placeBid(state, seat, amount) {
const s = clone(state);
const auc = s.pendingAuction;
if (amount > s.players[seat].cash) amount = s.players[seat].cash;
if (amount <= auc.highBid) return passAuction(s, seat);
auc.bids[seat] = amount;
auc.highBid = amount;
auc.highBidder = seat;
auc.currentBidderIdx = (auc.currentBidderIdx + 1) % auc.bidOrder.length;
// Skip to next active bidder who hasn't committed to their max
return advanceAuction(s);
}
export function passAuction(state, seat) {
const s = clone(state);
const auc = s.pendingAuction;
auc.bidOrder = auc.bidOrder.filter(bs => bs !== seat);
if (auc.currentBidderIdx >= auc.bidOrder.length) auc.currentBidderIdx = 0;
return advanceAuction(s);
}
function advanceAuction(state) {
const s = clone(state);
const auc = s.pendingAuction;
if (auc.bidOrder.length === 0 || (auc.bidOrder.length === 1 && auc.highBidder === auc.bidOrder[0])) {
return resolveAuction(s);
}
if (auc.bidOrder.length === 1 && auc.highBidder === null) {
return resolveAuction(s); // no one bid
}
return s;
}
function resolveAuction(state) {
const s = clone(state);
const auc = s.pendingAuction;
if (auc.highBidder !== null && auc.highBid > 0) {
s.players[auc.highBidder].cash -= auc.highBid;
s.board[auc.spaceIdx].owner = auc.highBidder;
log(s, `${s.players[auc.highBidder].name} wins auction for ${SPACES[auc.spaceIdx].name} at $${auc.highBid}.`);
} else {
log(s, `No one bid on ${SPACES[auc.spaceIdx].name} — it stays in the bank.`);
}
s.pendingAuction = null;
s.phase = s.doublesCount > 0 ? 'preroll' : 'endturn';
return checkGameOver(s);
}
// ── Building ──────────────────────────────────────────────────────────────────
export function canBuildHouse(state, seat, spaceIdx) {
const sp = SPACES[spaceIdx];
const own = state.board[spaceIdx];
if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false;
if (sp.type !== 'property') return false;
if (!ownsGroup(state, seat, sp.group)) return false;
if (state.houseSupply <= 0) return false;
// Even building: can't have more than 1 house more than any other in group
const groupMin = Math.min(...GROUPS[sp.group].map(i => state.board[i]?.houses ?? 0));
return own.houses <= groupMin && own.houses < 4;
}
export function buildHouse(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
s.players[seat].cash -= sp.houseCost;
s.board[spaceIdx].houses++;
s.houseSupply--;
log(s, `${s.players[seat].name} builds a house on ${sp.name}.`);
return s;
}
export function canBuildHotel(state, seat, spaceIdx) {
const sp = SPACES[spaceIdx];
const own = state.board[spaceIdx];
if (!own || own.owner !== seat || own.mortgaged || own.hotel) return false;
if (sp.type !== 'property') return false;
if (!ownsGroup(state, seat, sp.group)) return false;
if (state.hotelSupply <= 0) return false;
// All properties in group must have 4 houses
return GROUPS[sp.group].every(i => state.board[i]?.houses === 4 || state.board[i]?.hotel);
}
export function buildHotel(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
s.players[seat].cash -= sp.houseCost;
s.board[spaceIdx].houses = 0;
s.board[spaceIdx].hotel = true;
s.houseSupply += 4; // return the 4 houses
s.hotelSupply--;
log(s, `${s.players[seat].name} builds a hotel on ${sp.name}.`);
return s;
}
export function sellHouse(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
s.board[spaceIdx].houses--;
s.players[seat].cash += Math.floor(sp.houseCost / 2);
s.houseSupply++;
return s;
}
export function sellHotel(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
s.board[spaceIdx].hotel = false;
s.board[spaceIdx].houses = 0;
s.players[seat].cash += Math.floor(sp.houseCost / 2);
s.hotelSupply++;
s.houseSupply -= 4;
return s;
}
export function mortgageProperty(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
const own = s.board[spaceIdx];
if (own.houses > 0 || own.hotel) return s; // must sell buildings first
own.mortgaged = true;
s.players[seat].cash += sp.mortgage;
log(s, `${s.players[seat].name} mortgages ${sp.name} for $${sp.mortgage}.`);
return s;
}
export function unmortgageProperty(state, seat, spaceIdx) {
const s = clone(state);
const sp = SPACES[spaceIdx];
const cost = Math.ceil(sp.mortgage * 1.1);
if (s.players[seat].cash < cost) return s;
s.board[spaceIdx].mortgaged = false;
s.players[seat].cash -= cost;
log(s, `${s.players[seat].name} unmortgages ${sp.name} for $${cost}.`);
return s;
}
// ── Jail ──────────────────────────────────────────────────────────────────────
export function payJailFine(state, seat) {
const s = clone(state);
s.players[seat].cash -= 50;
s.players[seat].jailed = false;
s.players[seat].jailTurns = 0;
log(s, `${s.players[seat].name} pays $50 to get out of jail.`);
s.phase = 'preroll';
return s;
}
export function useJailCard(state, seat) {
const s = clone(state);
s.players[seat].getOutOfJailFree--;
s.players[seat].jailed = false;
s.players[seat].jailTurns = 0;
log(s, `${s.players[seat].name} uses a Get Out of Jail Free card.`);
s.phase = 'preroll';
return s;
}
// ── Turn ──────────────────────────────────────────────────────────────────────
export function endTurn(state) {
const s = clone(state);
// If doubles were rolled and player isn't jailed: same player rolls again
if (s.doublesCount > 0 && !s.players[s.current].jailed) {
s.phase = 'preroll';
return s;
}
s.doublesCount = 0;
// Advance to next active player
let next = (s.current + 1) % s.playerCount;
let guard = 0;
while (!s.players[next].active && guard++ < s.playerCount) {
next = (next + 1) % s.playerCount;
}
s.current = next;
s.diceRoll = null;
s.phase = 'preroll';
return checkGameOver(s);
}

View File

@ -0,0 +1,103 @@
# Monopoly — Spritesheet Guide
Three image files need to be created or extended. All dimensions are exact — the engine reads pixel-perfect frame boundaries.
---
## 1. `assets/images/monopoly-pawns.png`
**Total sheet size:** 320 × 80 px
**Frame size:** 80 × 80 px
**Layout:** 4 frames in a single horizontal row
**Displayed at:** 32 × 32 px on the board (the image is scaled down, so keep art centered and avoid fine detail near the edges)
Each frame is a single player token on a transparent background. The token should be centered in the 80 × 80 cell with roughly 810 px of breathing room on all sides so it doesn't clip when scaled.
| Frame | X offset | Player | Color theme | Suggested token |
|-------|----------|--------|-------------|-----------------|
| 0 | x=0 | Player 1 | Red `#E53935` | Top hat |
| 1 | x=80 | Player 2 | Blue `#1565C0` | Racing car |
| 2 | x=160 | Player 3 | Green `#2E7D32` | Scottie dog |
| 3 | x=240 | Player 4 | Gold `#F57F17` | Battleship |
### Visual guidance per token
**Top hat (frame 0 — red):**
Iconic tall cylinder hat. Brim at bottom, oval crown at top. Deep red or black body with a red highlight/shadow. Works well as a simple 2-tone silhouette.
**Racing car (frame 1 — blue):**
Classic 1930s open-wheel racer viewed at a slight 3/4 angle. Blue body, yellow or silver wheels. Keep it chunky and readable at 32 × 32 px — avoid thin spokes.
**Scottie dog (frame 2 — green):**
Side-on silhouette of a Scottish Terrier with wiry fur. Body in dark green or black-green; eyes as a small white or yellow dot. The iconic bushy-beard chin and erect tail are the key recognition cues.
**Battleship (frame 3 — gold):**
Top-down or slight 3/4 view of a naval destroyer. Gold hull, dark gray turrets, small white wake lines. Keep it horizontal/landscape within the square cell.
---
## 2. `assets/images/monopoly-cards.png`
**Total sheet size:** 400 × 300 px
**Frame size:** 200 × 300 px
**Layout:** 2 frames side by side in one row
**Displayed at:** 340 × 220 px inside the card popup (the engine scales the frame to fit the top area of a 360 × 480 popup)
The code overlays its own text labels on top — "CHANCE" or "COMMUNITY CHEST" is printed at the top by the engine, and the card's effect text is printed in the lower half. **The image only needs to fill the upper ~220 px of visible space** — think of it as providing the background art and mood, not the text.
| Frame | X offset | Card type | Background color | Icon |
|-------|----------|-----------|------------------|------|
| 0 | x=0 | Chance | Orange `#E77A2C` | Large "?" |
| 1 | x=200 | Community Chest | Blue `#1565C0` | Treasure chest |
### Frame 0 — Chance (200 × 300 px)
- **Background:** Warm orange gradient, darker toward the top and bottom edges
- **Border:** A thin ornate gold or cream inner border rect (≈ 8 px from each edge)
- **Central icon:** A large bold "?" character, roughly 100 × 140 px, in cream or white with a slight drop shadow. The "?" should be centered horizontally and sit in the lower ⅔ of the frame (the engine prints "CHANCE" at the very top, so leave the top 4050 px relatively clean)
- **Decorative touches (optional):** Small radiating lines or sunburst behind the "?", art deco corner flourishes
### Frame 1 — Community Chest (200 × 300 px)
- **Background:** Rich blue gradient, `#1565C0` center fading to `#0D3A6E` at edges
- **Border:** Same style ornate inner border as Chance, in gold or cream
- **Central icon:** A wooden treasure chest, slightly open, with a warm interior glow or gold coins visible. Roughly 80 × 70 px, centered horizontally, sitting in the lower ⅔ of the frame. Classic Monopoly Community Chest chests are brown/tan with gold hardware
- **Decorative touches (optional):** Small star or coin scattered around the chest, "art deco" corner ornaments matching the Chance card style for consistency
---
## 3. `assets/images/game-icons.png` — extend existing sheet
**Current sheet size:** 660 × 660 px
**Grid:** 15 columns × 15 rows of 44 × 44 px cells
**Current frames:** 0 47 (48 icons)
**Monopoly frame index:** **48**
### Where to paint frame 48
Frame 48 falls at:
```
Column = 48 % 15 = 3 → x = 3 × 44 = 132 px
Row = 48 / 15 = 3 → y = 3 × 44 = 132 px
```
**Paint the Monopoly icon into the cell at (132, 132) — a 44 × 44 px region — in your existing game-icons PSD.**
### Monopoly icon design (44 × 44 px)
The icon is shown at 44 × 44 px in the game menu next to the game title. Suggested design:
- **Background:** Deep green `#1F3D1F` or a classic Monopoly board green
- **Foreground:** A small white/cream Monopoly board viewed from above — simplified to just the four corner squares (a white square outline with the four corner squares indicated) OR simply the red "M" monogram from the Monopoly logo
- **Alternative:** A gold top hat silhouette centered on a dark background — very legible at small size and immediately recognizable
Keep the art to 23 colors at most. At 44 × 44 the icon is tiny; bold shapes with strong contrast read far better than detailed illustrations.
---
## Checklist
- [ ] `monopoly-pawns.png` — 4 tokens, 320 × 80 px, transparent background per token
- [ ] `monopoly-cards.png` — 2 card arts, 400 × 300 px, no text (engine adds text)
- [ ] `game-icons.png` — add Monopoly icon at pixel (132, 132) in the existing 660 × 660 sheet

View File

@ -58,6 +58,7 @@ import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
import FarkelGame from './games/farkel/FarkelGame.js';
import StrategoGame from './games/stratego/StrategoGame.js';
import KiitosGame from './games/kiitos/KiitosGame.js';
import MonopolyGame from './games/monopoly/MonopolyGame.js';
const config = {
type: Phaser.AUTO,
@ -129,6 +130,7 @@ const config = {
FarkelGame,
StrategoGame,
KiitosGame,
MonopolyGame,
],
};

View File

@ -22,7 +22,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', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -132,6 +132,10 @@ export default class PreloadScene extends Phaser.Scene {
// Stratego unit art: 12 transparent frames (0=Flag, 1..10=rank, 11=Bomb),
// 6 cols × 2 rows. Optional — the scene draws vector glyphs when absent.
this.load.spritesheet('stratego-pieces', '/assets/images/stratego-pieces.png', { frameWidth: 140, frameHeight: 140 });
// Monopoly pawns: 4 frames (one per seat) at 80×80. Optional — falls back to colored circles.
this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 });
// Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300.
this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 });
}
async create() {

View File

@ -73,3 +73,4 @@ registerGame({ slug: 'videopoker', name: 'Video Poker', category: '
registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });