Submission 2026-03-26 21:19

Approved

by ElFantasma on Mar 26, 2026 21:19 · v1

Reviewed by ElFantasma on Mar 26, 2026 21:19

Changes

diff --git a/games/quarto/about.md b/games/quarto/about.md
new file mode 100644
index 0000000..95d01b5
--- /dev/null
+++ b/games/quarto/about.md
@@ -0,0 +1,28 @@
+# Quarto
+
+A deceptively simple strategy game with a unique twist: your opponent chooses which piece you must play.
+
+## Rules
+
+### Pieces
+There are 16 unique pieces, each with 4 binary attributes:
+- **Tall** or **Short**
+- **Light** or **Dark**
+- **Round** or **Square**
+- **Hollow** or **Solid**
+
+Every combination exists exactly once.
+
+### Turns
+Each turn has two phases:
+
+1. **Place** the piece your opponent selected for you on any empty cell
+2. **Select** a piece from the remaining pool for your opponent to place next
+
+On the very first turn, the first player only selects a piece (nothing to place yet).
+
+### Winning
+Get **four pieces in a row** (horizontal, vertical, or diagonal) that share **at least one common attribute**. For example: four tall pieces, or four round pieces, regardless of their other traits.
+
+### Draw
+If all 16 pieces are placed with no four-in-a-row sharing an attribute, the game is a draw.
diff --git a/games/quarto/game.json b/games/quarto/game.json
new file mode 100644
index 0000000..3cd65c6
--- /dev/null
+++ b/games/quarto/game.json
@@ -0,0 +1,6 @@
+{
+  "title": "Quarto",
+  "description": "16 unique pieces, 4 binary traits. Your opponent picks which piece you must place. Get four in a row sharing any trait to win.",
+  "min_players": 2,
+  "max_players": 2
+}
diff --git a/games/quarto/logic.js b/games/quarto/logic.js
new file mode 100644
index 0000000..77b8518
--- /dev/null
+++ b/games/quarto/logic.js
@@ -0,0 +1,131 @@
+function getPlayers() {
+  return ["Gold", "Silver"];
+}
+function newGame(_params) {
+  return ser({
+    b: "..../..../..../....",
+    t: "Gold",
+    ph: "s",
+    sp: null,
+    av: "0123456789ABCDEF",
+    w: null
+  });
+}
+function getActivePlayers(json) {
+  const s = parse(json);
+  return s.w ? [] : [s.t];
+}
+function applyMove(json, moveJson, player) {
+  const s = parse(json);
+  const m = JSON.parse(moveJson);
+  if (s.w)
+    throw new Error("Game over");
+  if (s.t !== player)
+    throw new Error("Not your turn");
+  if (s.ph === "p") {
+    return doPlace(s, m.row, m.col, player);
+  } else {
+    return doSelect(s, m.piece, player);
+  }
+}
+function getPlayerState(json, _player) {
+  return json;
+}
+function getWinner(json) {
+  return parse(json).w;
+}
+function getValidMoves(json, player) {
+  const s = parse(json);
+  if (s.w || s.t !== player)
+    return [];
+  if (s.ph === "p") {
+    const cells = boardCells(s.b);
+    const moves = [];
+    for (let r = 0; r < 4; r++)
+      for (let c = 0; c < 4; c++)
+        if (cells[r][c] === ".")
+          moves.push(JSON.stringify({ row: r, col: c }));
+    return moves;
+  } else {
+    return [...s.av].map((p) => JSON.stringify({ piece: p }));
+  }
+}
+function parse(json) {
+  return JSON.parse(json);
+}
+function ser(s) {
+  return JSON.stringify(s);
+}
+function opp(p) {
+  return p === "Gold" ? "Silver" : "Gold";
+}
+function boardCells(b) {
+  return b.split("/").map((row) => [...row]);
+}
+function cellsToBoard(cells) {
+  return cells.map((row) => row.join("")).join("/");
+}
+function doPlace(s, row, col, player) {
+  if (row < 0 || row > 3 || col < 0 || col > 3)
+    throw new Error("Out of bounds");
+  if (!s.sp)
+    throw new Error("No piece to place");
+  const cells = boardCells(s.b);
+  if (cells[row][col] !== ".")
+    throw new Error("Cell occupied");
+  cells[row][col] = s.sp;
+  s.b = cellsToBoard(cells);
+  s.sp = null;
+  if (checkWin(cells)) {
+    s.w = player;
+    return ser(s);
+  }
+  if (s.av.length === 0) {
+    s.w = "draw";
+    return ser(s);
+  }
+  s.ph = "s";
+  return ser(s);
+}
+function doSelect(s, piece, player) {
+  if (!piece || piece.length !== 1)
+    throw new Error("Invalid piece");
+  const p = piece.toUpperCase();
+  if (!s.av.includes(p))
+    throw new Error("Piece not available");
+  s.av = s.av.replace(p, "");
+  s.sp = p;
+  s.t = opp(player);
+  s.ph = "p";
+  return ser(s);
+}
+function checkWin(cells) {
+  const lines = [];
+  for (let r = 0; r < 4; r++)
+    lines.push([[r, 0], [r, 1], [r, 2], [r, 3]]);
+  for (let c = 0; c < 4; c++)
+    lines.push([[0, c], [1, c], [2, c], [3, c]]);
+  lines.push([[0, 0], [1, 1], [2, 2], [3, 3]]);
+  lines.push([[0, 3], [1, 2], [2, 1], [3, 0]]);
+  for (const line of lines) {
+    const pieces = line.map(([r, c]) => cells[r][c]);
+    if (pieces.some((p) => p === "."))
+      continue;
+    const vals = pieces.map((p) => parseInt(p, 16));
+    for (let bit = 0; bit < 4; bit++) {
+      const bits = vals.map((v) => v >> bit & 1);
+      if (bits.every((b) => b === 1) || bits.every((b) => b === 0))
+        return true;
+    }
+  }
+  return false;
+}
+export {
+  applyMove,
+  getActivePlayers,
+  getPlayerState,
+  getPlayers,
+  getValidMoves,
+  getWinner,
+  newGame
+};
diff --git a/games/quarto/logic.ts b/games/quarto/logic.ts
new file mode 100644
index 0000000..18df4ae
--- /dev/null
+++ b/games/quarto/logic.ts
@@ -0,0 +1,153 @@
+// Quarto — Game Logic
+//
+// 4×4 board, 16 unique pieces (hex 0-F), each with 4 binary traits:
+//   bit 3: Tall(1)/Short(0)
+//   bit 2: Light(1)/Dark(0)
+//   bit 1: Round(1)/Square(0)
+//   bit 0: Hollow(1)/Solid(0)
+//
+// Board: "..../..../..../....", rows separated by /
+// State: { b, t, ph, sp, av, w }
+
+interface State {
+  b: string;        // board: 4 rows of 4 chars, "/" separated
+  t: string;        // turn: "Gold" | "Silver"
+  ph: string;       // phase: "s" (select) | "p" (place)
+  sp: string | null; // selected piece to be placed
+  av: string;       // available pieces (hex chars)
+  w: string | null;  // winner
+}
+
+// --- SDK exports ---
+
+export function getPlayers(): string[] { return ["Gold", "Silver"]; }
+
+export function newGame(_params: string): string {
+  return ser({
+    b: "..../..../..../....",
+    t: "Gold",
+    ph: "s",
+    sp: null,
+    av: "0123456789ABCDEF",
+    w: null,
+  });
+}
+
+export function getActivePlayers(json: string): string[] {
+  const s = parse(json);
+  return s.w ? [] : [s.t];
+}
+
+export function applyMove(json: string, moveJson: string, player: string): string {
+  const s = parse(json);
+  const m = JSON.parse(moveJson);
+  if (s.w) throw new Error("Game over");
+  if (s.t !== player) throw new Error("Not your turn");
+
+  if (s.ph === "p") {
+    return doPlace(s, m.row, m.col, player);
+  } else {
+    return doSelect(s, m.piece, player);
+  }
+}
+
+export function getPlayerState(json: string, _player: string | null): string { return json; }
+
+export function getWinner(json: string): string | null { return parse(json).w; }
+
+export function getValidMoves(json: string, player: string): string[] {
+  const s = parse(json);
+  if (s.w || s.t !== player) return [];
+
+  if (s.ph === "p") {
+    const cells = boardCells(s.b);
+    const moves: string[] = [];
+    for (let r = 0; r < 4; r++)
+      for (let c = 0; c < 4; c++)
+        if (cells[r][c] === ".") moves.push(JSON.stringify({ row: r, col: c }));
+    return moves;
+  } else {
+    return [...s.av].map(p => JSON.stringify({ piece: p }));
+  }
+}
+
+// --- Internal ---
+
+function parse(json: string): State { return JSON.parse(json); }
+function ser(s: State): string { return JSON.stringify(s); }
+function opp(p: string): string { return p === "Gold" ? "Silver" : "Gold"; }
+
+function boardCells(b: string): string[][] {
+  return b.split("/").map(row => [...row]);
+}
+
+function cellsToBoard(cells: string[][]): string {
+  return cells.map(row => row.join("")).join("/");
+}
+
+function doPlace(s: State, row: number, col: number, player: string): string {
+  if (row < 0 || row > 3 || col < 0 || col > 3) throw new Error("Out of bounds");
+  if (!s.sp) throw new Error("No piece to place");
+
+  const cells = boardCells(s.b);
+  if (cells[row][col] !== ".") throw new Error("Cell occupied");
+
+  cells[row][col] = s.sp;
+  s.b = cellsToBoard(cells);
+  s.sp = null;
+
+  // Check win
+  if (checkWin(cells)) {
+    s.w = player;
+    return ser(s);
+  }
+
+  // Check draw (no pieces left and no win)
+  if (s.av.length === 0) {
+    s.w = "draw";
+    return ser(s);
+  }
+
+  // Same player now selects a piece for opponent
+  s.ph = "s";
+  return ser(s);
+}
+
+function doSelect(s: State, piece: string, player: string): string {
+  if (!piece || piece.length !== 1) throw new Error("Invalid piece");
+  const p = piece.toUpperCase();
+  if (!s.av.includes(p)) throw new Error("Piece not available");
+
+  s.av = s.av.replace(p, "");
+  s.sp = p;
+  s.t = opp(player);
+  s.ph = "p";
+  return ser(s);
+}
+
+function checkWin(cells: string[][]): boolean {
+  const lines: [number, number][][] = [];
+
+  // Rows
+  for (let r = 0; r < 4; r++) lines.push([[r,0],[r,1],[r,2],[r,3]]);
+  // Cols
+  for (let c = 0; c < 4; c++) lines.push([[0,c],[1,c],[2,c],[3,c]]);
+  // Diagonals
+  lines.push([[0,0],[1,1],[2,2],[3,3]]);
+  lines.push([[0,3],[1,2],[2,1],[3,0]]);
+
+  for (const line of lines) {
+    const pieces = line.map(([r, c]) => cells[r][c]);
+    if (pieces.some(p => p === ".")) continue;
+
+    const vals = pieces.map(p => parseInt(p, 16));
+
+    // Check each bit: if all share the same value for any bit, it's a win
+    for (let bit = 0; bit < 4; bit++) {
+      const bits = vals.map(v => (v >> bit) & 1);
+      if (bits.every(b => b === 1) || bits.every(b => b === 0)) return true;
+    }
+  }
+
+  return false;
+}
diff --git a/games/quarto/params.json b/games/quarto/params.json
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/games/quarto/params.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/games/quarto/ui/default/main.js b/games/quarto/ui/default/main.js
new file mode 100644
index 0000000..0d3ffcf
--- /dev/null
+++ b/games/quarto/ui/default/main.js
@@ -0,0 +1,169 @@
+let inst = null;
+function mount(el, init) {
+  if (inst)
+    destroy();
+  inst = { el, cb: null, state: null, seat: null };
+  el.innerHTML = `<div class="qt-root">
+    <div class="qt-info"></div>
+    <div class="qt-board"></div>
+    <div class="qt-selected"></div>
+    <div class="qt-pool"></div>
+  </div>`;
+  if (init)
+    update(init);
+}
+function update(json) {
+  if (!inst)
+    return;
+  try {
+    inst.state = JSON.parse(json);
+  } catch {
+    return;
+  }
+  render();
+}
+function setSeat(n) {
+  if (!inst)
+    return;
+  inst.seat = n;
+  if (inst.state)
+    render();
+}
+function onMove(cb) {
+  if (inst)
+    inst.cb = cb;
+}
+function destroy() {
+  inst = null;
+}
+function setStatus(st) {
+  if (!inst)
+    return;
+  const info = inst.el.querySelector(".qt-info");
+  if (info && st === "waiting")
+    info.textContent = "Waiting for players\u2026";
+}
+function setResult(r) {
+  if (!inst)
+    return;
+  const info = inst.el.querySelector(".qt-info");
+  if (!info)
+    return;
+  if (r === "draw") {
+    info.textContent = "Draw!";
+  } else {
+    info.textContent = `${LBL[r] || r} wins!`;
+  }
+}
+const LBL = { Gold: "Gold", Silver: "Silver" };
+function me() {
+  return inst?.seat ? inst.seat === 1 ? "Gold" : "Silver" : null;
+}
+function render() {
+  if (!inst?.state)
+    return;
+  const s = inst.state;
+  const my = me();
+  const myTurn = my === s.t && !s.w;
+  const board = inst.el.querySelector(".qt-board");
+  const cells = s.b.split("/");
+  let bh = '<div class="qt-grid">';
+  for (let r = 0; r < 4; r++) {
+    for (let c = 0; c < 4; c++) {
+      const p = cells[r][c];
+      const clickable = myTurn && s.ph === "p" && p === ".";
+      bh += `<div class="qt-cell${clickable ? " qt-clickable" : ""}" data-r="${r}" data-c="${c}">`;
+      if (p !== ".")
+        bh += pieceSvg(p, "qt-piece-lg");
+      bh += `</div>`;
+    }
+  }
+  bh += "</div>";
+  board.innerHTML = bh;
+  board.querySelectorAll(".qt-clickable").forEach((el) => {
+    el.addEventListener("click", () => {
+      const r = parseInt(el.getAttribute("data-r"));
+      const c = parseInt(el.getAttribute("data-c"));
+      inst?.cb?.(JSON.stringify({ row: r, col: c }));
+    });
+  });
+  const selDiv = inst.el.querySelector(".qt-selected");
+  if (s.sp && s.ph === "p") {
+    const who = myTurn ? "You must place:" : `${LBL[s.t]} places:`;
+    selDiv.innerHTML = `<span class="qt-sel-label">${who}</span>${pieceSvg(s.sp, "qt-piece-sel")}`;
+  } else {
+    selDiv.innerHTML = "";
+  }
+  const pool = inst.el.querySelector(".qt-pool");
+  const pickable = myTurn && s.ph === "s";
+  let ph = "";
+  if (s.av.length > 0) {
+    ph += `<div class="qt-pool-label">${pickable ? "Pick a piece for your opponent:" : "Available pieces:"}</div>`;
+    ph += '<div class="qt-pool-grid">';
+    for (const p of s.av) {
+      ph += `<div class="qt-pool-item${pickable ? " qt-pickable" : ""}" data-piece="${p}">${pieceSvg(p, "qt-piece-sm")}</div>`;
+    }
+    ph += "</div>";
+  }
+  pool.innerHTML = ph;
+  pool.querySelectorAll(".qt-pickable").forEach((el) => {
+    el.addEventListener("click", () => {
+      const p = el.getAttribute("data-piece");
+      inst?.cb?.(JSON.stringify({ piece: p }));
+    });
+  });
+  const info = inst.el.querySelector(".qt-info");
+  if (s.w) {
+    info.textContent = s.w === "draw" ? "Draw!" : `${LBL[s.w]} wins!`;
+    info.className = "qt-info qt-result";
+  } else if (my === null) {
+    const act = s.ph === "s" ? "selects" : "places";
+    info.textContent = `${LBL[s.t]} ${act}`;
+    info.className = "qt-info";
+  } else if (myTurn) {
+    info.textContent = s.ph === "p" ? "Place the piece on the board" : "Pick a piece for your opponent";
+    info.className = "qt-info qt-myturn";
+  } else {
+    info.textContent = `${LBL[s.t]}'s turn`;
+    info.className = "qt-info";
+  }
+}
+function pieceSvg(hex, cls) {
+  const v = parseInt(hex, 16);
+  const tall = !!(v & 8);
+  const light = !!(v & 4);
+  const round = !!(v & 2);
+  const hollow = !!(v & 1);
+  const fill = light ? "#f5c542" : "#5b3e87";
+  const stroke = light ? "#d4a017" : "#3d2560";
+  const h = tall ? 40 : 26;
+  const y = tall ? 2 : 16;
+  const w = 30;
+  let shape;
+  if (round) {
+    const rx = w / 2, ry = h / 2;
+    const cx = 21, cy = y + h / 2;
+    if (hollow) {
+      shape = `<ellipse cx="${cx}" cy="${cy}" rx="${rx - 2}" ry="${ry - 2}" fill="none" stroke="${fill}" stroke-width="4"/>`;
+    } else {
+      shape = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`;
+    }
+  } else {
+    const x = 21 - w / 2;
+    if (hollow) {
+      shape = `<rect x="${x + 2}" y="${y + 2}" width="${w - 4}" height="${h - 4}" rx="2" fill="none" stroke="${fill}" stroke-width="4"/>`;
+    } else {
+      shape = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="3" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`;
+    }
+  }
+  return `<svg class="${cls}" viewBox="0 0 42 44">${shape}</svg>`;
+}
+export {
+  destroy,
+  mount,
+  onMove,
+  setResult,
+  setSeat,
+  setStatus,
+  update
+};
diff --git a/games/quarto/ui/default/main.ts b/games/quarto/ui/default/main.ts
new file mode 100644
index 0000000..8c5047b
--- /dev/null
+++ b/games/quarto/ui/default/main.ts
@@ -0,0 +1,182 @@
+// Quarto — UI Module
+//
+// Piece encoding (hex 0-F):
+//   bit 3: Tall(1)/Short(0)
+//   bit 2: Light(1)/Dark(0)
+//   bit 1: Round(1)/Square(0)
+//   bit 0: Hollow(1)/Solid(0)
+
+interface State {
+  b: string; t: string; ph: string;
+  sp: string | null; av: string; w: string | null;
+}
+
+let inst: {
+  el: HTMLElement; cb: ((m: string) => void) | null;
+  state: State | null; seat: number | null;
+} | null = null;
+
+// --- SDK exports ---
+
+export function mount(el: HTMLElement, init?: string) {
+  if (inst) destroy();
+  inst = { el, cb: null, state: null, seat: null };
+  el.innerHTML = `<div class="qt-root">
+    <div class="qt-info"></div>
+    <div class="qt-board"></div>
+    <div class="qt-selected"></div>
+    <div class="qt-pool"></div>
+  </div>`;
+  if (init) update(init);
+}
+
+export function update(json: string) {
+  if (!inst) return;
+  try { inst.state = JSON.parse(json); } catch { return; }
+  render();
+}
+
+export function setSeat(n: number) {
+  if (!inst) return;
+  inst.seat = n;
+  if (inst.state) render();
+}
+
+export function onMove(cb: (m: string) => void) { if (inst) inst.cb = cb; }
+export function destroy() { inst = null; }
+
+export function setStatus(st: string) {
+  if (!inst) return;
+  const info = inst.el.querySelector(".qt-info");
+  if (info && st === "waiting") info.textContent = "Waiting for players\u2026";
+}
+
+export function setResult(r: string) {
+  if (!inst) return;
+  const info = inst.el.querySelector(".qt-info");
+  if (!info) return;
+  if (r === "draw") { info.textContent = "Draw!"; }
+  else { info.textContent = `${LBL[r] || r} wins!`; }
+}
+
+// --- Internal ---
+
+const LBL: Record<string, string> = { Gold: "Gold", Silver: "Silver" };
+
+function me(): string | null {
+  return inst?.seat ? (inst.seat === 1 ? "Gold" : "Silver") : null;
+}
+
+function render() {
+  if (!inst?.state) return;
+  const s = inst.state;
+  const my = me();
+  const myTurn = my === s.t && !s.w;
+
+  // Board
+  const board = inst.el.querySelector(".qt-board")!;
+  const cells = s.b.split("/");
+  let bh = '<div class="qt-grid">';
+  for (let r = 0; r < 4; r++) {
+    for (let c = 0; c < 4; c++) {
+      const p = cells[r][c];
+      const clickable = myTurn && s.ph === "p" && p === ".";
+      bh += `<div class="qt-cell${clickable ? " qt-clickable" : ""}" data-r="${r}" data-c="${c}">`;
+      if (p !== ".") bh += pieceSvg(p, "qt-piece-lg");
+      bh += `</div>`;
+    }
+  }
+  bh += "</div>";
+  board.innerHTML = bh;
+
+  board.querySelectorAll(".qt-clickable").forEach(el => {
+    el.addEventListener("click", () => {
+      const r = parseInt(el.getAttribute("data-r")!);
+      const c = parseInt(el.getAttribute("data-c")!);
+      inst?.cb?.(JSON.stringify({ row: r, col: c }));
+    });
+  });
+
+  // Selected piece (waiting to be placed)
+  const selDiv = inst.el.querySelector(".qt-selected") as HTMLElement;
+  if (s.sp && s.ph === "p") {
+    const who = myTurn ? "You must place:" : `${LBL[s.t]} places:`;
+    selDiv.innerHTML = `<span class="qt-sel-label">${who}</span>${pieceSvg(s.sp, "qt-piece-sel")}`;
+  } else {
+    selDiv.innerHTML = "";
+  }
+
+  // Pool
+  const pool = inst.el.querySelector(".qt-pool") as HTMLElement;
+  const pickable = myTurn && s.ph === "s";
+  let ph = "";
+  if (s.av.length > 0) {
+    ph += `<div class="qt-pool-label">${pickable ? "Pick a piece for your opponent:" : "Available pieces:"}</div>`;
+    ph += '<div class="qt-pool-grid">';
+    for (const p of s.av) {
+      ph += `<div class="qt-pool-item${pickable ? " qt-pickable" : ""}" data-piece="${p}">${pieceSvg(p, "qt-piece-sm")}</div>`;
+    }
+    ph += "</div>";
+  }
+  pool.innerHTML = ph;
+
+  pool.querySelectorAll(".qt-pickable").forEach(el => {
+    el.addEventListener("click", () => {
+      const p = el.getAttribute("data-piece")!;
+      inst?.cb?.(JSON.stringify({ piece: p }));
+    });
+  });
+
+  // Info
+  const info = inst.el.querySelector(".qt-info") as HTMLElement;
+  if (s.w) {
+    info.textContent = s.w === "draw" ? "Draw!" : `${LBL[s.w]} wins!`;
+    info.className = "qt-info qt-result";
+  } else if (my === null) {
+    const act = s.ph === "s" ? "selects" : "places";
+    info.textContent = `${LBL[s.t]} ${act}`;
+    info.className = "qt-info";
+  } else if (myTurn) {
+    info.textContent = s.ph === "p" ? "Place the piece on the board" : "Pick a piece for your opponent";
+    info.className = "qt-info qt-myturn";
+  } else {
+    info.textContent = `${LBL[s.t]}'s turn`;
+    info.className = "qt-info";
+  }
+}
+
+// Piece SVG rendering
+// Traits: Tall/Short × Light/Dark × Round/Square × Hollow/Solid
+function pieceSvg(hex: string, cls: string): string {
+  const v = parseInt(hex, 16);
+  const tall = !!(v & 8);
+  const light = !!(v & 4);
+  const round = !!(v & 2);
+  const hollow = !!(v & 1);
+
+  const fill = light ? "#f5c542" : "#5b3e87";
+  const stroke = light ? "#d4a017" : "#3d2560";
+  const h = tall ? 40 : 26;
+  const y = tall ? 2 : 16;
+  const w = 30;
+
+  let shape: string;
+  if (round) {
+    const rx = w / 2, ry = h / 2;
+    const cx = 21, cy = y + h / 2;
+    if (hollow) {
+      shape = `<ellipse cx="${cx}" cy="${cy}" rx="${rx - 2}" ry="${ry - 2}" fill="none" stroke="${fill}" stroke-width="4"/>`;
+    } else {
+      shape = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`;
+    }
+  } else {
+    const x = 21 - w / 2;
+    if (hollow) {
+      shape = `<rect x="${x + 2}" y="${y + 2}" width="${w - 4}" height="${h - 4}" rx="2" fill="none" stroke="${fill}" stroke-width="4"/>`;
+    } else {
+      shape = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="3" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`;
+    }
+  }
+
+  return `<svg class="${cls}" viewBox="0 0 42 44">${shape}</svg>`;
+}
diff --git a/games/quarto/ui/default/ui.css b/games/quarto/ui/default/ui.css
new file mode 100644
index 0000000..2801106
--- /dev/null
+++ b/games/quarto/ui/default/ui.css
@@ -0,0 +1,133 @@
+/* Quarto */
+
+.qt-root {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem;
+  max-width: 480px;
+  margin: 0 auto;
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+  color: rgb(var(--c-primary, 24 24 27));
+  user-select: none;
+  -webkit-user-select: none;
+}
+
+/* Board */
+.qt-board {
+  width: 100%;
+  max-width: 360px;
+}
+
+.qt-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 4px;
+  width: 100%;
+  aspect-ratio: 1;
+  background: rgb(var(--c-edge-light, 228 228 231));
+  border-radius: 8px;
+  padding: 4px;
+}
+
+.qt-cell {
+  background: rgb(var(--c-surface, 255 255 255));
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  aspect-ratio: 1;
+  transition: background 0.15s;
+}
+
+.qt-cell.qt-clickable {
+  cursor: pointer;
+}
+
+.qt-cell.qt-clickable:hover {
+  background: rgb(var(--c-surface-alt, 244 244 245));
+  box-shadow: inset 0 0 0 2px #22c55e66;
+}
+
+/* Piece sizes */
+.qt-piece-lg {
+  width: 70%;
+  height: 70%;
+}
+
+.qt-piece-sm {
+  width: 100%;
+  height: 100%;
+}
+
+.qt-piece-sel {
+  width: 40px;
+  height: 40px;
+}
+
+/* Selected piece banner */
+.qt-selected {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  min-height: 2.5rem;
+}
+
+.qt-sel-label {
+  font-size: 0.85rem;
+  font-weight: 600;
+  color: rgb(var(--c-secondary, 63 63 70));
+}
+
+/* Piece pool */
+.qt-pool {
+  width: 100%;
+  max-width: 360px;
+}
+
+.qt-pool-label {
+  font-size: 0.8rem;
+  font-weight: 600;
+  color: rgb(var(--c-muted, 113 113 122));
+  margin-bottom: 0.25rem;
+  text-align: center;
+}
+
+.qt-pool-grid {
+  display: grid;
+  grid-template-columns: repeat(8, 1fr);
+  gap: 4px;
+}
+
+.qt-pool-item {
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgb(var(--c-surface-muted, 250 250 250));
+  border-radius: 4px;
+  padding: 2px;
+  transition: background 0.15s, box-shadow 0.15s;
+}
+
+.qt-pool-item.qt-pickable {
+  cursor: pointer;
+}
+
+.qt-pool-item.qt-pickable:hover {
+  background: rgb(var(--c-surface-alt, 244 244 245));
+  box-shadow: 0 0 0 2px #facc15;
+}
+
+/* Info */
+.qt-info {
+  text-align: center;
+  font-size: 0.85rem;
+  font-weight: 600;
+  padding: 0.2rem 0.5rem;
+  color: rgb(var(--c-muted, 113 113 122));
+}
+
+.qt-myturn { color: #facc15; }
+.qt-result { color: #22c55e; font-size: 1rem; }