Explorar o código

virtual 1v1 with p2p

simo hai 2 días
pai
achega
f9fee9d66a

BIN=BIN
data/klask.sqlite


+ 596 - 0
src/components/VirtualGame.astro

@@ -0,0 +1,596 @@
+---
+interface Props {
+  gameId: string;
+  isHost: boolean;
+  player1: string;
+  player2: string;
+  currentUser: string;
+}
+const { gameId, isHost, player1, player2, currentUser } = Astro.props;
+const svgId = `vg-${Math.random().toString(36).slice(2, 8)}`;
+---
+
+<div class="virtual-game" id={`${svgId}-wrap`}>
+  <!-- HUD -->
+  <div class="vg-hud">
+    <div class="vg-player vg-p1">
+      <span class="vg-pname">{player1}</span>
+      <span class="vg-pscore" id={`${svgId}-s1`}>0</span>
+    </div>
+    <div class="vg-center-info">
+      <span class="vg-status" id={`${svgId}-status`}>Connecting...</span>
+      <span class="vg-ft6">First to 6</span>
+    </div>
+    <div class="vg-player vg-p2">
+      <span class="vg-pscore" id={`${svgId}-s2`}>0</span>
+      <span class="vg-pname">{player2}</span>
+    </div>
+  </div>
+
+  <!-- Flash overlay for goals -->
+  <div class="vg-flash" id={`${svgId}-flash`}></div>
+
+  <!-- Board SVG -->
+  <svg
+    id={svgId}
+    class="vg-board"
+    viewBox="0 0 400 260"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <defs>
+      <linearGradient id={`vgsheen-${svgId}`} x1="0" y1="0" x2="0" y2="1">
+        <stop offset="0%" stop-color="#fff"/>
+        <stop offset="100%" stop-color="#fff" stop-opacity="0"/>
+      </linearGradient>
+      <radialGradient id={`vgball-${svgId}`} cx="35%" cy="30%" r="65%">
+        <stop offset="0%" stop-color="#fff" stop-opacity="0.9"/>
+        <stop offset="40%" stop-color="#ffee55"/>
+        <stop offset="100%" stop-color="#e6b800"/>
+      </radialGradient>
+    </defs>
+
+    <rect width="400" height="260" rx="12" fill="#1a1a2e"/>
+    <rect x="1" y="1" width="398" height="258" rx="12" fill="none" stroke="#2a2a4a" stroke-width="1.5"/>
+    <rect x="12" y="12" width="376" height="236" rx="8" fill="#1565c0"/>
+    <rect x="12" y="12" width="376" height="80" rx="8" fill={`url(#vgsheen-${svgId})`} opacity="0.12"/>
+    <line x1="200" y1="12" x2="200" y2="248" stroke="#fff" stroke-width="1.5" opacity="0.25"/>
+    <path d="M12,12 A28,28 0 0,1 40,12 A28,28 0 0,1 12,40 Z" fill="#fff" opacity="0.85"/>
+    <path d="M388,12 A28,28 0 0,0 360,12 A28,28 0 0,0 388,40 Z" fill="#fff" opacity="0.85"/>
+    <path d="M12,248 A28,28 0 0,0 40,248 A28,28 0 0,0 12,220 Z" fill="#fff" opacity="0.85"/>
+    <path d="M388,248 A28,28 0 0,1 360,248 A28,28 0 0,1 388,220 Z" fill="#fff" opacity="0.85"/>
+
+    <!-- Goals -->
+    <circle cx="36" cy="130" r="22" fill="#0d1b3e"/>
+    <circle cx="36" cy="130" r="18" fill="#0a1530"/>
+    <circle cx="36" cy="130" r="14" fill="#060d1e"/>
+    <circle cx="36" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
+    <circle cx="364" cy="130" r="22" fill="#0d1b3e"/>
+    <circle cx="364" cy="130" r="18" fill="#0a1530"/>
+    <circle cx="364" cy="130" r="14" fill="#060d1e"/>
+    <circle cx="364" cy="130" r="22" fill="none" stroke="#4a6fa5" stroke-width="2"/>
+
+    <!-- Biscuits -->
+    <g id={`${svgId}-b0`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
+    <g id={`${svgId}-b1`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
+    <g id={`${svgId}-b2`}><circle r="6" fill="#e8e6e0"/><circle r="4.5" fill="#d0cdc6"/><circle r="2.5" fill="#b8b5ae"/></g>
+
+    <!-- Ball -->
+    <g id={`${svgId}-ball`}><circle r="8" fill={`url(#vgball-${svgId})`}/><circle r="8" fill="none" stroke="#c8960a" stroke-width="0.8" opacity="0.6"/></g>
+
+    <!-- Pucks -->
+    <g id={`${svgId}-p1`}><circle r="11" fill="#111"/><circle r="9" fill="#1c1c1c"/><circle r="6" fill="#262626"/><circle r="11" fill="none" stroke="#383838" stroke-width="1.2"/></g>
+    <g id={`${svgId}-p2`}><circle r="11" fill="#111"/><circle r="9" fill="#1c1c1c"/><circle r="6" fill="#262626"/><circle r="11" fill="none" stroke="#383838" stroke-width="1.2"/></g>
+
+    <rect x="12" y="12" width="376" height="236" rx="8" fill="none" stroke="#1e88e5" stroke-width="1.5" opacity="0.6"/>
+
+    <!-- Touch area (invisible, captures touch/mouse) -->
+    <rect x="12" y="12" width="376" height="236" rx="8" fill="transparent" id={`${svgId}-touch`} style="cursor:crosshair"/>
+  </svg>
+
+  <p class="vg-controls-hint" id={`${svgId}-hint`}>Arrow keys or drag to move your puck</p>
+</div>
+
+<script src="https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js" is:inline></script>
+
+<script define:vars={{ svgId, gameId, isHost, player1, player2, currentUser }}>
+(function() {
+  const statusEl = document.getElementById(`${svgId}-status`);
+  const s1El = document.getElementById(`${svgId}-s1`);
+  const s2El = document.getElementById(`${svgId}-s2`);
+  const flashEl = document.getElementById(`${svgId}-flash`);
+  const ballEl = document.getElementById(`${svgId}-ball`);
+  const p1El = document.getElementById(`${svgId}-p1`);
+  const p2El = document.getElementById(`${svgId}-p2`);
+  const bEls = [0,1,2].map(i => document.getElementById(`${svgId}-b${i}`));
+  const touchArea = document.getElementById(`${svgId}-touch`);
+  const svg = document.getElementById(svgId);
+
+  // Physics constants
+  const FX1 = 24, FX2 = 376, FY1 = 24, FY2 = 236, CX = 200;
+  const GOAL_L = { x: 36, y: 130, r: 22 };
+  const GOAL_R = { x: 364, y: 130, r: 22 };
+  const BALL_R = 8, BISC_R = 6, PUCK_R = 11;
+  const ATTACH_DIST = PUCK_R + BISC_R + 2;
+  const STUCK_ORBIT = PUCK_R + BISC_R - 2;
+  const WIN_SCORE = 6;
+
+  // Serve positions (loser's corners)
+  const SERVE_P1 = [{ x: 55, y: 45 }, { x: 55, y: 215 }]; // P1's corners
+  const SERVE_P2 = [{ x: 345, y: 45 }, { x: 345, y: 215 }]; // P2's corners
+
+  // Game state (host is authoritative)
+  let state = {
+    ball: { x: 200, y: 130, vx: 0, vy: 0 },
+    biscs: [
+      { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 },
+      { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 },
+      { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 },
+    ],
+    p1: { x: 95, y: 130, vx: 0, vy: 0, attached: 0 },
+    p2: { x: 305, y: 130, vx: 0, vy: 0, attached: 0 },
+    score1: 0,
+    score2: 0,
+    paused: true,
+    serving: false,
+    gameOver: false,
+  };
+
+  let myInput = { tx: isHost ? 95 : 305, ty: 130 }; // target position from input
+  let remoteInput = { tx: isHost ? 305 : 95, ty: 130 };
+  let conn = null;
+  let peer = null;
+  let connected = false;
+
+  // --- Physics helpers ---
+  function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.sqrt(dx*dx + dy*dy); }
+  function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
+  function spd(o) { return Math.sqrt(o.vx*o.vx + o.vy*o.vy); }
+  function cap(o, m) { const s = spd(o); if (s > m) { o.vx = (o.vx/s)*m; o.vy = (o.vy/s)*m; } }
+
+  function collide(a, ar, b, br, bMass) {
+    const d = dist(a.x, a.y, b.x, b.y);
+    const minD = ar + br;
+    if (d >= minD || d < 0.01) return;
+    const nx = (a.x - b.x) / d, ny = (a.y - b.y) / d;
+    const dvx = a.vx - b.vx, dvy = a.vy - b.vy;
+    const dot = dvx * nx + dvy * ny;
+    if (dot > 0) return;
+    const tm = 1 + bMass;
+    a.vx -= (2 * bMass / tm) * dot * nx; a.vy -= (2 * bMass / tm) * dot * ny;
+    b.vx += (2 / tm) * dot * nx; b.vy += (2 / tm) * dot * ny;
+    const overlap = minD - d + 0.5;
+    a.x += nx * overlap * (bMass/tm); a.y += ny * overlap * (bMass/tm);
+    b.x -= nx * overlap * (1/tm); b.y -= ny * overlap * (1/tm);
+  }
+
+  function wallBounce(o, r) {
+    if (o.x - r < FX1) { o.x = FX1 + r; o.vx = Math.abs(o.vx) * 0.85; }
+    if (o.x + r > FX2) { o.x = FX2 - r; o.vx = -Math.abs(o.vx) * 0.85; }
+    if (o.y - r < FY1) { o.y = FY1 + r; o.vy = Math.abs(o.vy) * 0.85; }
+    if (o.y + r > FY2) { o.y = FY2 - r; o.vy = -Math.abs(o.vy) * 0.85; }
+  }
+
+  // Drive a puck toward a target
+  function drivePuck(p, tx, ty, side) {
+    const margin = PUCK_R + 4;
+    // Clamp target to half
+    if (side === 1) tx = clamp(tx, FX1 + margin, CX - margin);
+    else tx = clamp(tx, CX + margin, FX2 - margin);
+    ty = clamp(ty, FY1 + margin, FY2 - margin);
+    const dx = tx - p.x, dy = ty - p.y;
+    // Stiff spring: move most of the way to target each frame, tiny residual slide
+    p.vx = dx * 0.35;
+    p.vy = dy * 0.35;
+    cap(p, 6);
+    p.x += p.vx; p.y += p.vy;
+    if (side === 1) p.x = clamp(p.x, FX1 + margin, CX - margin);
+    else p.x = clamp(p.x, CX + margin, FX2 - margin);
+    p.y = clamp(p.y, FY1 + margin, FY2 - margin);
+  }
+
+  function serve(loserSide) {
+    // Ball starts at a random corner on the loser's side, stationary
+    const corners = loserSide === 1 ? SERVE_P1 : SERVE_P2;
+    const c = corners[Math.floor(Math.random() * 2)];
+    state.ball.x = c.x; state.ball.y = c.y;
+    state.ball.vx = 0; state.ball.vy = 0;
+    // Reset biscuits
+    state.biscs[0] = { x: 200, y: 88, vx: 0, vy: 0, stuck: null, angle: 0 };
+    state.biscs[1] = { x: 200, y: 130, vx: 0, vy: 0, stuck: null, angle: 0 };
+    state.biscs[2] = { x: 200, y: 172, vx: 0, vy: 0, stuck: null, angle: 0 };
+    state.p1.attached = 0; state.p2.attached = 0;
+    // Reset puck positions
+    state.p1.x = 95; state.p1.y = 130; state.p1.vx = 0; state.p1.vy = 0;
+    state.p2.x = 305; state.p2.y = 130; state.p2.vx = 0; state.p2.vy = 0;
+    // Pause briefly then launch ball
+    state.serving = true;
+    setTimeout(() => {
+      // Launch ball toward center with slight randomness
+      const angle = (loserSide === 1 ? 0 : Math.PI) + (Math.random() - 0.5) * 0.8;
+      state.ball.vx = Math.cos(angle) * 3;
+      state.ball.vy = Math.sin(angle) * 3;
+      state.serving = false;
+    }, 1000);
+  }
+
+  function scoreGoal(scorerSide) {
+    if (state.gameOver) return;
+    if (scorerSide === 1) state.score1++; else state.score2++;
+    // Flash
+    flashEl.style.opacity = "1";
+    setTimeout(() => { flashEl.style.opacity = "0"; }, 300);
+    // Check win
+    if (state.score1 >= WIN_SCORE || state.score2 >= WIN_SCORE) {
+      state.gameOver = true;
+      state.paused = true;
+      const winner = state.score1 >= WIN_SCORE ? player1 : player2;
+      statusEl.textContent = `${winner} wins!`;
+      // Submit result to server
+      fetch(`/api/game/${gameId}/complete`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ winner, score1: state.score1, score2: state.score2 }),
+      }).then(() => {
+        setTimeout(() => { location.reload(); }, 2000);
+      });
+      return;
+    }
+    // Loser serves from their corner
+    const loserSide = scorerSide === 1 ? 2 : 1; // scorer is NOT the loser
+    serve(loserSide);
+  }
+
+  // Stuck detection
+  let stuckX = 200, stuckY = 130, stuckTimer = 0;
+
+  // --- Host physics tick ---
+  function physicsTick(dt) {
+    if (state.paused || state.serving || state.gameOver) return;
+
+    const { ball, biscs, p1, p2 } = state;
+
+    // Drive pucks from inputs
+    const hostInput = isHost ? myInput : remoteInput;
+    const guestInput = isHost ? remoteInput : myInput;
+    drivePuck(p1, hostInput.tx, hostInput.ty, 1);
+    drivePuck(p2, guestInput.tx, guestInput.ty, 2);
+
+    // Ball movement
+    ball.x += ball.vx; ball.y += ball.vy;
+    ball.vx *= 0.999; ball.vy *= 0.999;
+
+    // Stuck detection
+    if (dist(ball.x, ball.y, stuckX, stuckY) < 15) {
+      stuckTimer += dt;
+      if (stuckTimer > 3000) {
+        // Nudge ball randomly instead of full reset
+        ball.vx += (Math.random() - 0.5) * 4;
+        ball.vy += (Math.random() - 0.5) * 4;
+        stuckTimer = 0;
+      }
+    } else { stuckX = ball.x; stuckY = ball.y; stuckTimer = 0; }
+
+    // Min speed
+    if (spd(ball) < 1.2) { const s = spd(ball)||1; ball.vx=(ball.vx/s)*1.2; ball.vy=(ball.vy/s)*1.2; }
+
+    // Biscuit logic
+    for (const b of biscs) {
+      if (b.stuck !== null) {
+        const puck = b.stuck === 1 ? p1 : p2;
+        b.angle += 0.02;
+        b.x = puck.x + Math.cos(b.angle) * STUCK_ORBIT;
+        b.y = puck.y + Math.sin(b.angle) * STUCK_ORBIT;
+        b.vx = 0; b.vy = 0;
+      } else {
+        b.x += b.vx; b.y += b.vy;
+        b.vx *= 0.97; b.vy *= 0.97;
+      }
+    }
+
+    // Collisions
+    wallBounce(ball, BALL_R);
+    for (const b of biscs) { if (b.stuck === null) wallBounce(b, BISC_R); }
+    collide(ball, BALL_R, p1, PUCK_R, 5);
+    collide(ball, BALL_R, p2, PUCK_R, 5);
+    for (const b of biscs) { if (b.stuck === null) collide(ball, BALL_R, b, BISC_R, 1); }
+    for (let i = 0; i < biscs.length; i++) {
+      for (let j = i+1; j < biscs.length; j++) {
+        if (biscs[i].stuck === null && biscs[j].stuck === null) collide(biscs[i], BISC_R, biscs[j], BISC_R, 1);
+      }
+    }
+    for (const b of biscs) {
+      if (b.stuck !== null) collide(ball, BALL_R, b, BISC_R, 5);
+      else { collide(b, BISC_R, p1, PUCK_R, 5); collide(b, BISC_R, p2, PUCK_R, 5); }
+    }
+
+    // Attachment
+    for (const b of biscs) {
+      if (b.stuck !== null) continue;
+      if (dist(b.x, b.y, p1.x, p1.y) < ATTACH_DIST) { b.stuck = 1; b.angle = Math.atan2(b.y-p1.y, b.x-p1.x); p1.attached++; }
+      else if (dist(b.x, b.y, p2.x, p2.y) < ATTACH_DIST) { b.stuck = 2; b.angle = Math.atan2(b.y-p2.y, b.x-p2.x); p2.attached++; }
+    }
+
+    // 2 biscuits = point for opponent
+    if (p1.attached >= 2) { scoreGoal(2); } // P2 scores
+    else if (p2.attached >= 2) { scoreGoal(1); } // P1 scores
+
+    // Goal detection
+    if (dist(ball.x, ball.y, GOAL_L.x, GOAL_L.y) < GOAL_L.r - 4) { scoreGoal(2); } // P2 scores (ball in P1's goal)
+    else if (dist(ball.x, ball.y, GOAL_R.x, GOAL_R.y) < GOAL_R.r - 4) { scoreGoal(1); } // P1 scores (ball in P2's goal)
+
+    cap(ball, 7);
+    for (const b of biscs) { if (b.stuck === null) cap(b, 5); }
+  }
+
+  // --- Render ---
+  function render() {
+    const { ball, biscs, p1, p2 } = state;
+    ballEl.setAttribute("transform", `translate(${ball.x.toFixed(1)},${ball.y.toFixed(1)})`);
+    p1El.setAttribute("transform", `translate(${p1.x.toFixed(1)},${p1.y.toFixed(1)})`);
+    p2El.setAttribute("transform", `translate(${p2.x.toFixed(1)},${p2.y.toFixed(1)})`);
+    bEls.forEach((el, i) => el.setAttribute("transform", `translate(${biscs[i].x.toFixed(1)},${biscs[i].y.toFixed(1)})`));
+    s1El.textContent = state.score1;
+    s2El.textContent = state.score2;
+  }
+
+  // --- Input handling ---
+  const keys = {};
+  const PUCK_SPEED = 4;
+
+  document.addEventListener("keydown", (e) => {
+    if (["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].includes(e.key)) {
+      e.preventDefault();
+      keys[e.key] = true;
+    }
+  });
+  document.addEventListener("keyup", (e) => { keys[e.key] = false; });
+
+  function updateInputFromKeys() {
+    let dx = 0, dy = 0;
+    if (keys["ArrowLeft"]) dx -= PUCK_SPEED;
+    if (keys["ArrowRight"]) dx += PUCK_SPEED;
+    if (keys["ArrowUp"]) dy -= PUCK_SPEED;
+    if (keys["ArrowDown"]) dy += PUCK_SPEED;
+    myInput.tx += dx;
+    myInput.ty += dy;
+    // Clamp to own half
+    const margin = PUCK_R + 4;
+    if (isHost) {
+      myInput.tx = clamp(myInput.tx, FX1 + margin, CX - margin);
+    } else {
+      myInput.tx = clamp(myInput.tx, CX + margin, FX2 - margin);
+    }
+    myInput.ty = clamp(myInput.ty, FY1 + margin, FY2 - margin);
+  }
+
+  // Touch / mouse drag
+  let dragging = false;
+  function svgCoords(e) {
+    const rect = svg.getBoundingClientRect();
+    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
+    const clientY = e.touches ? e.touches[0].clientY : e.clientY;
+    const scaleX = 400 / rect.width;
+    const scaleY = 260 / rect.height;
+    return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY };
+  }
+
+  touchArea.addEventListener("mousedown", (e) => { dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; });
+  touchArea.addEventListener("mousemove", (e) => { if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } });
+  document.addEventListener("mouseup", () => { dragging = false; });
+  touchArea.addEventListener("touchstart", (e) => { e.preventDefault(); dragging = true; const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; }, { passive: false });
+  touchArea.addEventListener("touchmove", (e) => { e.preventDefault(); if (dragging) { const c = svgCoords(e); myInput.tx = c.x; myInput.ty = c.y; } }, { passive: false });
+  touchArea.addEventListener("touchend", () => { dragging = false; });
+
+  // --- PeerJS networking ---
+  const peerId = `klask-${gameId}`;
+
+  function setupPeer() {
+    statusEl.textContent = "Connecting...";
+    peer = new Peer(isHost ? peerId : undefined);
+
+    peer.on("open", (id) => {
+      if (isHost) {
+        statusEl.textContent = "Waiting for opponent...";
+        peer.on("connection", (c) => {
+          conn = c;
+          setupConnection();
+        });
+      } else {
+        conn = peer.connect(peerId, { reliable: true });
+        setupConnection();
+      }
+    });
+
+    peer.on("error", (err) => {
+      console.error("PeerJS error:", err);
+      if (err.type === "peer-unavailable") {
+        statusEl.textContent = "Host not found. Retrying...";
+        setTimeout(setupPeer, 2000);
+      } else {
+        statusEl.textContent = "Connection error";
+      }
+    });
+  }
+
+  function setupConnection() {
+    conn.on("open", () => {
+      connected = true;
+      state.paused = false;
+      statusEl.textContent = "Live";
+      // Start with serve from P1's corner
+      if (isHost) serve(1);
+    });
+
+    conn.on("data", (data) => {
+      if (isHost) {
+        // Guest sends input
+        if (data.type === "input") {
+          remoteInput.tx = data.tx;
+          remoteInput.ty = data.ty;
+        }
+      } else {
+        // Host sends full state
+        if (data.type === "state") {
+          state.ball = data.ball;
+          state.biscs = data.biscs;
+          state.p1 = data.p1;
+          state.p2 = data.p2;
+          state.score1 = data.score1;
+          state.score2 = data.score2;
+          state.gameOver = data.gameOver;
+          state.paused = data.paused;
+          if (data.gameOver && data.winner) {
+            statusEl.textContent = `${data.winner} wins!`;
+          }
+        }
+      }
+    });
+
+    conn.on("close", () => {
+      connected = false;
+      state.paused = true;
+      statusEl.textContent = "Disconnected";
+    });
+  }
+
+  // --- Main loop ---
+  let last = performance.now();
+  let sendTimer = 0;
+
+  function tick(now) {
+    const dt = Math.min(now - last, 32);
+    last = now;
+
+    updateInputFromKeys();
+
+    if (isHost && connected) {
+      physicsTick(dt);
+
+      // Send state to guest at ~30fps
+      sendTimer += dt;
+      if (sendTimer > 33 && conn && conn.open) {
+        sendTimer = 0;
+        try {
+          conn.send({
+            type: "state",
+            ball: state.ball,
+            biscs: state.biscs,
+            p1: state.p1,
+            p2: state.p2,
+            score1: state.score1,
+            score2: state.score2,
+            gameOver: state.gameOver,
+            paused: state.paused,
+            winner: state.gameOver ? (state.score1 >= WIN_SCORE ? player1 : player2) : null,
+          });
+        } catch {}
+      }
+    }
+
+    if (!isHost && connected && conn && conn.open) {
+      sendTimer += dt;
+      if (sendTimer > 33) {
+        sendTimer = 0;
+        try { conn.send({ type: "input", tx: myInput.tx, ty: myInput.ty }); } catch {}
+      }
+    }
+
+    render();
+    requestAnimationFrame(tick);
+  }
+
+  // Init
+  render();
+  setupPeer();
+  requestAnimationFrame(tick);
+})();
+</script>
+
+<style>
+  .virtual-game {
+    position: relative;
+    width: 100%;
+    max-width: 640px;
+    margin: 0 auto;
+  }
+
+  .vg-hud {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+    margin-bottom: 12px;
+    padding: 8px 12px;
+    background: var(--bg-darker);
+    border: 1px solid var(--border);
+    border-radius: 4px;
+  }
+
+  .vg-player {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  .vg-pname {
+    font-size: 13px;
+    color: var(--text);
+  }
+
+  .vg-pscore {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: 2.2rem;
+    color: var(--text);
+    line-height: 1;
+  }
+
+  .vg-center-info {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 2px;
+  }
+
+  .vg-status {
+    font-size: 11px;
+    color: var(--green);
+    letter-spacing: 0.05em;
+    text-transform: uppercase;
+  }
+
+  .vg-ft6 {
+    font-size: 9px;
+    color: var(--text-dim);
+    letter-spacing: 0.08em;
+    text-transform: uppercase;
+  }
+
+  .vg-board {
+    width: 100%;
+    height: auto;
+    display: block;
+    border-radius: 12px;
+    box-shadow: 0 8px 32px rgba(0,0,0,0.5);
+  }
+
+  .vg-flash {
+    position: absolute;
+    top: 44px;
+    left: 0;
+    right: 0;
+    bottom: 28px;
+    border-radius: 12px;
+    background: rgba(255, 255, 255, 0.3);
+    pointer-events: none;
+    opacity: 0;
+    transition: opacity 0.3s ease;
+  }
+
+  .vg-controls-hint {
+    text-align: center;
+    font-size: 11px;
+    color: var(--text-dim);
+    margin-top: 8px;
+    letter-spacing: 0.03em;
+  }
+</style>

+ 37 - 12
src/pages/api/game/[id]/complete.ts

@@ -17,16 +17,40 @@ export const POST: APIRoute = async ({ request, params }) => {
     return json({ error: "Not a participant" }, 403);
   }
 
-  // Determine winner by score
-  const winner = game.score1 > game.score2 ? game.player1 : game.player2;
-  const loser = winner === game.player1 ? game.player2 : game.player1;
+  // Allow override winner from body (for virtual games where host detects)
+  let winner: string;
+  let loser: string;
+  try {
+    const body = await request.json();
+    if (body.winner && (body.winner === game.player1 || body.winner === game.player2)) {
+      winner = body.winner;
+    } else {
+      winner = game.score1 > game.score2 ? game.player1 : game.player2;
+    }
+    if (typeof body.score1 === "number" && typeof body.score2 === "number") {
+      await updateGame(game.id, { score1: body.score1, score2: body.score2 });
+    }
+  } catch {
+    winner = game.score1 > game.score2 ? game.player1 : game.player2;
+  }
+  loser = winner === game.player1 ? game.player2 : game.player1;
 
   const winnerUser = await getUser(winner);
   const loserUser = await getUser(loser);
 
   if (!winnerUser || !loserUser) return json({ error: "Players not found" }, 500);
 
-  const { winnerNew, loserNew } = calculateNewRatings(winnerUser, loserUser);
+  // Use the correct rating based on game mode
+  const mode = game.mode;
+  const winnerRating = mode === "virtual" ? winnerUser.rating_online : winnerUser.rating;
+  const loserRating = mode === "virtual" ? loserUser.rating_online : loserUser.rating;
+  const winnerGames = mode === "virtual" ? winnerUser.games_played_online : winnerUser.games_played;
+  const loserGames = mode === "virtual" ? loserUser.games_played_online : loserUser.games_played;
+
+  const { winnerNew, loserNew } = calculateNewRatings(
+    { rating: winnerRating, games_played: winnerGames },
+    { rating: loserRating, games_played: loserGames },
+  );
 
   // Update game
   await updateGame(game.id, {
@@ -35,20 +59,21 @@ export const POST: APIRoute = async ({ request, params }) => {
     completed_at: new Date().toISOString(),
   });
 
-  // Update player stats
-  await updateUserStats(winner, winnerNew, true);
-  await updateUserStats(loser, loserNew, false);
+  // Update player stats with mode
+  await updateUserStats(winner, winnerNew, true, mode);
+  await updateUserStats(loser, loserNew, false, mode);
 
-  // Record rating history
-  await addRatingHistory(winner, winnerNew, game.id);
-  await addRatingHistory(loser, loserNew, game.id);
+  // Record rating history with mode
+  await addRatingHistory(winner, winnerNew, game.id, mode);
+  await addRatingHistory(loser, loserNew, game.id, mode);
 
-  const winnerChange = winnerNew - winnerUser.rating;
-  const loserChange = loserNew - loserUser.rating;
+  const winnerChange = winnerNew - winnerRating;
+  const loserChange = loserNew - loserRating;
 
   return json({
     ok: true,
     winner,
+    mode,
     ratingChanges: {
       [winner]: winnerChange,
       [loser]: loserChange,

+ 9 - 2
src/pages/api/game/index.ts

@@ -1,5 +1,6 @@
 import type { APIRoute } from "astro";
 import { init, createGame, getRecentGames, json } from "@utils/db";
+import type { GameMode } from "@utils/db";
 import { getUserFromRequest } from "@utils/auth";
 
 export const GET: APIRoute = async () => {
@@ -14,8 +15,14 @@ export const POST: APIRoute = async ({ request }) => {
   const currentUser = getUserFromRequest(request);
   if (!currentUser) return json({ error: "Not authenticated" }, 401);
 
+  let mode: GameMode = "inperson";
+  try {
+    const body = await request.json();
+    if (body.mode === "virtual") mode = "virtual";
+  } catch {}
+
   const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
-  const game = await createGame(id, currentUser);
+  const game = await createGame(id, currentUser, mode);
 
-  return json({ ok: true, id: game.id });
+  return json({ ok: true, id: game.id, mode: game.mode });
 };

+ 185 - 362
src/pages/game/[id].astro

@@ -2,6 +2,7 @@
 import Layout from "@layouts/layout.astro";
 import Nav from "@components/Nav.astro";
 import KlaskBoard from "@components/KlaskBoard.astro";
+import VirtualGame from "@components/VirtualGame.astro";
 import { init, getGame, getUser } from "@utils/db";
 import { getUserFromRequest } from "@utils/auth";
 
@@ -25,6 +26,8 @@ const canJoin = !isParticipant && game.status === "waiting" && currentUserName &
 
 const origin = new URL(Astro.request.url).origin;
 const gameUrl = `${origin}/game/${id}`;
+
+const isVirtual = game.mode === "virtual";
 ---
 
 <Layout
@@ -35,15 +38,21 @@ const gameUrl = `${origin}/game/${id}`;
   <Nav currentUser={currentUserName} currentRating={currentUser?.rating ?? null} />
 
   <main class="container game-page">
-    <div class="game-layout">
-      <!-- Board side -->
-      <div class="board-side">
-        <KlaskBoard size="lg" />
-      </div>
+    <div class="mode-badge-row">
+      <span class:list={["mode-badge", isVirtual ? "mode-virtual" : "mode-inperson"]}>
+        {isVirtual ? "🌐 Virtual" : "🏓 In Person"}
+      </span>
+    </div>
 
-      <!-- Score side -->
-      <div class="score-side">
-        {game.status === "waiting" && (
+    {/* --- WAITING STATE (both modes) --- */}
+    {game.status === "waiting" && (
+      <div class="waiting-layout">
+        {!isVirtual && (
+          <div class="board-side">
+            <KlaskBoard size="lg" />
+          </div>
+        )}
+        <div class="score-side">
           <div class="waiting-panel card">
             <p class="section-label">Waiting for opponent</p>
             <h2 class="waiting-title">Share this game</h2>
@@ -68,11 +77,29 @@ const gameUrl = `${origin}/game/${id}`;
               Created by <strong>{game.player1}</strong>
             </p>
           </div>
-        )}
-
-        {game.status !== "waiting" && game.status !== "complete" && (
+        </div>
+      </div>
+    )}
+
+    {/* --- VIRTUAL ACTIVE GAME --- */}
+    {isVirtual && game.status === "active" && game.player2 && (
+      <VirtualGame
+        gameId={id}
+        isHost={isPlayer1}
+        player1={game.player1}
+        player2={game.player2}
+        currentUser={currentUserName ?? ""}
+      />
+    )}
+
+    {/* --- IN-PERSON ACTIVE GAME --- */}
+    {!isVirtual && game.status !== "waiting" && game.status !== "complete" && (
+      <div class="game-layout">
+        <div class="board-side">
+          <KlaskBoard size="lg" />
+        </div>
+        <div class="score-side">
           <div class="scoreboard">
-            <!-- Player 2 (top) -->
             <div class="player-row player-top" id="p2-row">
               <div class="player-name-wrap">
                 <span class="player-label-badge p2-badge">●</span>
@@ -81,31 +108,18 @@ const gameUrl = `${origin}/game/${id}`;
                 </a>
                 {isPlayer2 && <span class="you-tag">you</span>}
               </div>
-              <button
-                class="score-btn score-btn-p2"
-                id="score-p2"
-                data-player="2"
-                disabled={!isParticipant || undefined}
-              >
+              <button class="score-btn score-btn-p2" id="score-p2" data-player="2" disabled={!isParticipant || undefined}>
                 <span class="score-num" id="score2">{game.score2}</span>
                 <span class="score-plus">+1</span>
               </button>
             </div>
 
-            <!-- Progress bars -->
             <div class="progress-bars">
-              <div class="progress-bar">
-                <div class="progress-fill p1-fill" id="p1-fill" style={`width: ${(game.score1 / 6) * 100}%`}></div>
-              </div>
-              <div class="progress-divider">
-                <span class="progress-label">First to 6</span>
-              </div>
-              <div class="progress-bar">
-                <div class="progress-fill p2-fill" id="p2-fill" style={`width: ${(game.score2 / 6) * 100}%`}></div>
-              </div>
+              <div class="progress-bar"><div class="progress-fill p1-fill" id="p1-fill" style={`width: ${(game.score1 / 6) * 100}%`}></div></div>
+              <div class="progress-divider"><span class="progress-label">First to 6</span></div>
+              <div class="progress-bar"><div class="progress-fill p2-fill" id="p2-fill" style={`width: ${(game.score2 / 6) * 100}%`}></div></div>
             </div>
 
-            <!-- Player 1 (bottom) -->
             <div class="player-row player-bottom" id="p1-row">
               <div class="player-name-wrap">
                 <span class="player-label-badge p1-badge">●</span>
@@ -114,12 +128,7 @@ const gameUrl = `${origin}/game/${id}`;
                 </a>
                 {isPlayer1 && <span class="you-tag">you</span>}
               </div>
-              <button
-                class="score-btn score-btn-p1"
-                id="score-p1"
-                data-player="1"
-                disabled={!isParticipant || undefined}
-              >
+              <button class="score-btn score-btn-p1" id="score-p1" data-player="1" disabled={!isParticipant || undefined}>
                 <span class="score-num" id="score1">{game.score1}</span>
                 <span class="score-plus">+1</span>
               </button>
@@ -140,69 +149,97 @@ const gameUrl = `${origin}/game/${id}`;
               <p class="muted" style="font-size:12px; text-align:center; margin-top:16px">Spectating</p>
             )}
           </div>
-        )}
-
-        {game.status === "complete" && (
-          <div class="result-panel card">
-            <p class="section-label">Game Over</p>
-            <h2 class="result-winner">
-              {game.winner} <span class="green">wins!</span>
-            </h2>
-            <div class="final-score">
-              <span>{game.player1}</span>
-              <span class="final-score-num">{game.score1} — {game.score2}</span>
-              <span>{game.player2}</span>
-            </div>
-            <div id="rating-changes" class="rating-changes"></div>
-            <div style="display:flex; gap:8px; margin-top:20px">
-              <a href="/play" class="btn" style="flex:1; text-align:center; padding:11px">New Game</a>
-              <a href="/" class="btn btn-secondary" style="flex:1; text-align:center; padding:11px">Home</a>
-            </div>
+        </div>
+      </div>
+    )}
+
+    {/* --- COMPLETE (both modes) --- */}
+    {game.status === "complete" && (
+      <div class="result-center">
+        <div class="result-panel card">
+          <p class="section-label">Game Over</p>
+          <h2 class="result-winner">
+            {game.winner} <span class="green">wins!</span>
+          </h2>
+          <div class="final-score">
+            <span>{game.player1}</span>
+            <span class="final-score-num">{game.score1} — {game.score2}</span>
+            <span>{game.player2}</span>
           </div>
-        )}
+          <div id="rating-changes" class="rating-changes"></div>
+          <div style="display:flex; gap:8px; margin-top:20px">
+            <a href="/play" class="btn" style="flex:1; text-align:center; padding:11px">New Game</a>
+            <a href="/" class="btn btn-secondary" style="flex:1; text-align:center; padding:11px">Home</a>
+          </div>
+        </div>
       </div>
-    </div>
+    )}
   </main>
 </Layout>
 
-<script define:vars={{ gameId: id, initialStatus: game.status, isParticipant, gameUrl }}>
-  let pollInterval = null;
+<script define:vars={{ gameId: id, initialStatus: game.status, isParticipant, gameUrl, isVirtual }}>
+  // Only run polling/button logic for in-person games
+  if (!isVirtual) {
+    let pollInterval = null;
+
+    async function pollGame() {
+      const res = await fetch(`/api/game/${gameId}`);
+      if (!res.ok) return;
+      const game = await res.json();
+      const s1 = document.getElementById("score1");
+      const s2 = document.getElementById("score2");
+      if (s1) s1.textContent = game.score1;
+      if (s2) s2.textContent = game.score2;
+      const p1Fill = document.getElementById("p1-fill");
+      const p2Fill = document.getElementById("p2-fill");
+      if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
+      if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
+      if (game.status === "active" && initialStatus === "waiting") location.reload();
+      if (game.status === "complete" && initialStatus !== "complete") location.reload();
+    }
 
-  function timeAgo(iso) {
-    const diff = Date.now() - new Date(iso).getTime();
-    const mins = Math.floor(diff / 60000);
-    if (mins < 1) return "just now";
-    if (mins < 60) return `${mins}m ago`;
-    return `${Math.floor(mins / 60)}h ago`;
-  }
+    document.querySelectorAll(".score-btn").forEach(btn => {
+      btn.addEventListener("click", async () => {
+        const player = btn.dataset.player;
+        btn.disabled = true;
+        const res = await fetch(`/api/game/${gameId}/score`, {
+          method: "PATCH",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ player: parseInt(player) }),
+        });
+        btn.disabled = false;
+        if (res.ok) {
+          const game = await res.json();
+          const s1 = document.getElementById("score1");
+          const s2 = document.getElementById("score2");
+          if (s1) s1.textContent = game.score1;
+          if (s2) s2.textContent = game.score2;
+          const p1Fill = document.getElementById("p1-fill");
+          const p2Fill = document.getElementById("p2-fill");
+          if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
+          if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
+        }
+      });
+    });
 
-  async function pollGame() {
-    const res = await fetch(`/api/game/${gameId}`);
-    if (!res.ok) return;
-    const game = await res.json();
-
-    // Update scores
-    const s1 = document.getElementById("score1");
-    const s2 = document.getElementById("score2");
-    if (s1) s1.textContent = game.score1;
-    if (s2) s2.textContent = game.score2;
-
-    // Update progress bars
-    const p1Fill = document.getElementById("p1-fill");
-    const p2Fill = document.getElementById("p2-fill");
-    if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
-    if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
-
-    if (game.status === "active" && initialStatus === "waiting") {
-      location.reload();
+    const endBtn = document.getElementById("end-game-btn");
+    if (endBtn) {
+      endBtn.addEventListener("click", async () => {
+        if (!confirm("End the game and save results?")) return;
+        endBtn.disabled = true;
+        endBtn.textContent = "Saving...";
+        const res = await fetch(`/api/game/${gameId}/complete`, { method: "POST" });
+        if (res.ok) location.reload();
+        else { endBtn.disabled = false; endBtn.textContent = "End & Submit Result"; }
+      });
     }
 
-    if (game.status === "complete" && initialStatus !== "complete") {
-      location.reload();
+    if (initialStatus === "waiting" || initialStatus === "active") {
+      pollInterval = setInterval(pollGame, 3000);
     }
   }
 
-  // Copy link button
+  // Shared: waiting state buttons
   const copyLinkBtn = document.getElementById("copy-link-btn");
   if (copyLinkBtn) {
     copyLinkBtn.addEventListener("click", () => {
@@ -212,311 +249,97 @@ const gameUrl = `${origin}/game/${id}`;
     });
   }
 
-  // Join button
   const joinBtn = document.getElementById("join-btn");
   if (joinBtn) {
     joinBtn.addEventListener("click", async () => {
       joinBtn.disabled = true;
       const res = await fetch(`/api/game/${gameId}`, { method: "POST" });
-      if (res.ok) {
-        location.reload();
-      } else {
-        joinBtn.disabled = false;
-        alert("Failed to join game.");
-      }
-    });
-  }
-
-  // Score buttons
-  document.querySelectorAll(".score-btn").forEach(btn => {
-    btn.addEventListener("click", async () => {
-      const player = btn.dataset.player;
-      btn.disabled = true;
-      const res = await fetch(`/api/game/${gameId}/score`, {
-        method: "PATCH",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ player: parseInt(player) }),
-      });
-      btn.disabled = false;
-      if (res.ok) {
-        const game = await res.json();
-        const s1 = document.getElementById("score1");
-        const s2 = document.getElementById("score2");
-        if (s1) s1.textContent = game.score1;
-        if (s2) s2.textContent = game.score2;
-        const p1Fill = document.getElementById("p1-fill");
-        const p2Fill = document.getElementById("p2-fill");
-        if (p1Fill) p1Fill.style.width = `${(game.score1 / 6) * 100}%`;
-        if (p2Fill) p2Fill.style.width = `${(game.score2 / 6) * 100}%`;
-      }
-    });
-  });
-
-  // End game button
-  const endBtn = document.getElementById("end-game-btn");
-  if (endBtn) {
-    endBtn.addEventListener("click", async () => {
-      if (!confirm("End the game and save results?")) return;
-      endBtn.disabled = true;
-      endBtn.textContent = "Saving...";
-      const res = await fetch(`/api/game/${gameId}/complete`, { method: "POST" });
-      if (res.ok) {
-        location.reload();
-      } else {
-        endBtn.disabled = false;
-        endBtn.textContent = "End & Submit Result";
-        alert("Failed to complete game.");
-      }
+      if (res.ok) location.reload();
+      else { joinBtn.disabled = false; alert("Failed to join game."); }
     });
   }
 
-  // Poll if game is active or waiting
-  if (initialStatus === "waiting" || initialStatus === "active") {
-    pollInterval = setInterval(pollGame, 3000);
+  // For virtual waiting, also poll for opponent join
+  if (isVirtual && initialStatus === "waiting") {
+    setInterval(async () => {
+      const res = await fetch(`/api/game/${gameId}`);
+      if (!res.ok) return;
+      const game = await res.json();
+      if (game.status === "active") location.reload();
+    }, 2000);
   }
 </script>
 
 <style>
-  .game-page {
-    padding: 32px 16px 48px;
+  .game-page { padding: 24px 16px 48px; }
+
+  .mode-badge-row { margin-bottom: 16px; }
+  .mode-badge {
+    display: inline-block;
+    font-size: 11px;
+    letter-spacing: 0.06em;
+    padding: 4px 10px;
+    border-radius: 3px;
+    text-transform: uppercase;
   }
+  .mode-inperson { background: rgba(129,182,76,0.12); color: var(--green); border: 1px solid rgba(129,182,76,0.25); }
+  .mode-virtual { background: rgba(90,140,220,0.12); color: #7aabee; border: 1px solid rgba(90,140,220,0.25); }
 
-  .game-layout {
+  .waiting-layout, .game-layout {
     display: flex;
     gap: 40px;
     align-items: flex-start;
     justify-content: center;
   }
 
-  .board-side {
-    flex: 0 0 auto;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-  }
-
-  .score-side {
-    flex: 0 0 320px;
-  }
-
-  /* Waiting panel */
-  .waiting-panel {
-    text-align: center;
-  }
-
-  .waiting-title {
-    font-family: 'Bebas Neue', sans-serif;
-    font-size: 1.6rem;
-    letter-spacing: 0.05em;
-    margin-bottom: 16px;
-  }
-
-  .link-row {
-    display: flex;
-    gap: 8px;
-  }
-
-  .link-row input {
-    font-size: 12px;
-  }
-
-  .qr-wrap {
-    display: flex;
-    justify-content: center;
-    margin: 0 auto;
-  }
-
-  .qr-img {
-    width: 160px;
-    height: 160px;
-    background: white;
-    padding: 10px;
-    border-radius: 4px;
-    display: block;
-  }
-
-  /* Scoreboard */
-  .scoreboard {
-    display: flex;
-    flex-direction: column;
-    gap: 16px;
-  }
-
-  .player-row {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    gap: 12px;
-  }
-
-  .player-name-wrap {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    flex: 1;
-  }
+  .board-side { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; }
+  .score-side { flex: 0 0 320px; }
 
-  .player-label-badge {
-    font-size: 10px;
-  }
+  .waiting-panel { text-align: center; }
+  .waiting-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; letter-spacing: 0.05em; margin-bottom: 16px; }
+  .link-row { display: flex; gap: 8px; }
+  .link-row input { font-size: 12px; }
+  .qr-wrap { display: flex; justify-content: center; margin: 0 auto; }
+  .qr-img { width: 160px; height: 160px; background: white; padding: 10px; border-radius: 4px; display: block; }
 
+  .scoreboard { display: flex; flex-direction: column; gap: 16px; }
+  .player-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+  .player-name-wrap { display: flex; align-items: center; gap: 8px; flex: 1; }
+  .player-label-badge { font-size: 10px; }
   .p1-badge { color: #5c8bd6; }
   .p2-badge { color: #d46060; }
-
-  .player-link {
-    color: var(--text);
-    text-decoration: none;
-    font-size: 14px;
-    font-weight: 500;
-  }
-
+  .player-link { color: var(--text); text-decoration: none; font-size: 14px; font-weight: 500; }
   .player-link:hover { color: var(--green-light); }
-
-  .you-tag {
-    font-size: 10px;
-    color: var(--green);
-    background: rgba(129,182,76,0.15);
-    border: 1px solid rgba(129,182,76,0.3);
-    border-radius: 3px;
-    padding: 1px 5px;
-  }
-
-  .score-btn {
-    background: var(--card);
-    border: 1px solid var(--border);
-    border-radius: 4px;
-    color: var(--text);
-    font-family: 'DM Mono', monospace;
-    cursor: pointer;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    padding: 8px 16px;
-    gap: 2px;
-    transition: background 0.1s, transform 0.08s;
-    min-width: 72px;
-  }
-
-  .score-btn:hover:not(:disabled) {
-    background: var(--card-hover);
-  }
-
-  .score-btn:active:not(:disabled) {
-    transform: scale(0.96);
-  }
-
-  .score-btn:disabled {
-    cursor: default;
-    opacity: 0.7;
-  }
-
-  .score-btn-p1:hover:not(:disabled) {
-    border-color: #5c8bd6;
-  }
-
-  .score-btn-p2:hover:not(:disabled) {
-    border-color: #d46060;
-  }
-
-  .score-num {
-    font-family: 'Bebas Neue', sans-serif;
-    font-size: 3.5rem;
-    line-height: 1;
-    letter-spacing: 0.02em;
-  }
-
-  .score-plus {
-    font-size: 10px;
-    color: var(--text-muted);
-    letter-spacing: 0.05em;
-  }
-
-  .score-btn:disabled .score-plus {
-    display: none;
-  }
-
-  /* Progress bars */
-  .progress-bars {
-    display: flex;
-    flex-direction: column;
-    gap: 6px;
-  }
-
-  .progress-bar {
-    height: 6px;
-    background: var(--bg-darker);
-    border-radius: 3px;
-    overflow: hidden;
-  }
-
-  .progress-fill {
-    height: 100%;
-    border-radius: 3px;
-    transition: width 0.3s ease;
-  }
-
+  .you-tag { font-size: 10px; color: var(--green); background: rgba(129,182,76,0.15); border: 1px solid rgba(129,182,76,0.3); border-radius: 3px; padding: 1px 5px; }
+
+  .score-btn { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-family: 'DM Mono', monospace; cursor: pointer; display: flex; flex-direction: column; align-items: center; padding: 8px 16px; gap: 2px; transition: background 0.1s, transform 0.08s; min-width: 72px; }
+  .score-btn:hover:not(:disabled) { background: var(--card-hover); }
+  .score-btn:active:not(:disabled) { transform: scale(0.96); }
+  .score-btn:disabled { cursor: default; opacity: 0.7; }
+  .score-btn-p1:hover:not(:disabled) { border-color: #5c8bd6; }
+  .score-btn-p2:hover:not(:disabled) { border-color: #d46060; }
+  .score-num { font-family: 'Bebas Neue', sans-serif; font-size: 3.5rem; line-height: 1; letter-spacing: 0.02em; }
+  .score-plus { font-size: 10px; color: var(--text-muted); letter-spacing: 0.05em; }
+  .score-btn:disabled .score-plus { display: none; }
+
+  .progress-bars { display: flex; flex-direction: column; gap: 6px; }
+  .progress-bar { height: 6px; background: var(--bg-darker); border-radius: 3px; overflow: hidden; }
+  .progress-fill { height: 100%; border-radius: 3px; transition: width 0.3s ease; }
   .p1-fill { background: #5c8bd6; }
   .p2-fill { background: #d46060; }
+  .progress-divider { text-align: center; }
+  .progress-label { font-size: 10px; color: var(--text-dim); letter-spacing: 0.08em; text-transform: uppercase; }
 
-  .progress-divider {
-    text-align: center;
-  }
-
-  .progress-label {
-    font-size: 10px;
-    color: var(--text-dim);
-    letter-spacing: 0.08em;
-    text-transform: uppercase;
-  }
-
-  /* Result panel */
-  .result-panel {
-    text-align: center;
-  }
-
-  .result-winner {
-    font-family: 'Bebas Neue', sans-serif;
-    font-size: 2.2rem;
-    letter-spacing: 0.05em;
-    margin-bottom: 16px;
-  }
-
-  .final-score {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 16px;
-    color: var(--text-muted);
-    font-size: 13px;
-    margin-bottom: 8px;
-  }
-
-  .final-score-num {
-    font-family: 'Bebas Neue', sans-serif;
-    font-size: 2rem;
-    color: var(--text);
-  }
-
-  .rating-changes {
-    font-size: 13px;
-    color: var(--text-muted);
-    margin-top: 8px;
-  }
-
-  .game-actions {
-    margin-top: 8px;
-  }
+  .result-center { display: flex; justify-content: center; }
+  .result-panel { text-align: center; max-width: 400px; width: 100%; }
+  .result-winner { font-family: 'Bebas Neue', sans-serif; font-size: 2.2rem; letter-spacing: 0.05em; margin-bottom: 16px; }
+  .final-score { display: flex; align-items: center; justify-content: center; gap: 16px; color: var(--text-muted); font-size: 13px; margin-bottom: 8px; }
+  .final-score-num { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; color: var(--text); }
+  .rating-changes { font-size: 13px; color: var(--text-muted); margin-top: 8px; }
+  .game-actions { margin-top: 8px; }
 
   @media (max-width: 800px) {
-    .game-layout {
-      flex-direction: column;
-      align-items: center;
-    }
-
-    .score-side {
-      flex: 0 0 auto;
-      width: 100%;
-      max-width: 480px;
-    }
+    .game-layout, .waiting-layout { flex-direction: column; align-items: center; }
+    .score-side { flex: 0 0 auto; width: 100%; max-width: 480px; }
   }
 </style>

+ 11 - 10
src/pages/leaderboard.astro

@@ -28,18 +28,18 @@ const players = await getLeaderboard(50);
             <tr>
               <th style="width:48px">#</th>
               <th>Player</th>
-              <th>Rating</th>
-              <th>W</th>
-              <th>L</th>
-              <th>Games</th>
+              <th>IRL</th>
+              <th>Online</th>
+              <th>W/L</th>
               <th>Win %</th>
             </tr>
           </thead>
           <tbody>
             {players.map((p, i) => {
-              const winRate = p.games_played > 0
-                ? Math.round((p.wins / p.games_played) * 100)
-                : 0;
+              const totalW = p.wins + p.wins_online;
+              const totalL = p.losses + p.losses_online;
+              const totalG = p.games_played + p.games_played_online;
+              const winRate = totalG > 0 ? Math.round((totalW / totalG) * 100) : 0;
               const isMe = p.name === currentUserName;
               return (
                 <tr class:list={[{ "me-row": isMe }]}>
@@ -56,9 +56,8 @@ const players = await getLeaderboard(50);
                     {isMe && <span class="you-tag">you</span>}
                   </td>
                   <td><span class="rating-badge">{p.rating}</span></td>
-                  <td class="win">{p.wins}</td>
-                  <td class="loss">{p.losses}</td>
-                  <td class="muted">{p.games_played}</td>
+                  <td><span class="rating-badge rating-online">{p.rating_online}</span></td>
+                  <td class="muted">{totalW}/{totalL}</td>
                   <td>
                     <div class="winrate-wrap">
                       <div class="winrate-bar">
@@ -87,6 +86,8 @@ const players = await getLeaderboard(50);
     font-size: 13px;
   }
 
+  .rating-online { color: #7aabee; }
+
   .rank-gold  { color: var(--gold); }
   .rank-silver { color: var(--silver); }
   .rank-bronze { color: var(--bronze); }

+ 75 - 72
src/pages/play.astro

@@ -20,11 +20,25 @@ const currentUser = await getUser(currentUserName);
   <main class="container play-page">
     <h1 class="page-title" style="margin: 32px 0 24px">Start a Game</h1>
 
+    <!-- Mode selector -->
+    <div class="mode-tabs">
+      <button class="mode-tab active" id="tab-inperson" data-mode="inperson">
+        <span class="mode-icon">🏓</span>
+        <span class="mode-label">In Person</span>
+        <span class="mode-desc">Track score for a physical game</span>
+      </button>
+      <button class="mode-tab" id="tab-virtual" data-mode="virtual">
+        <span class="mode-icon">🌐</span>
+        <span class="mode-label">Virtual</span>
+        <span class="mode-desc">Play online with PeerJS</span>
+      </button>
+    </div>
+
     <div class="play-grid">
       <!-- Create Game -->
       <div class="card">
         <h2 class="card-title">Create Game</h2>
-        <p class="card-desc">Generate a link and share it with your opponent. Scan the QR code or copy the URL.</p>
+        <p class="card-desc" id="create-desc">Generate a link and share it with your opponent.</p>
 
         <button id="create-btn" class="btn" style="width:100%; padding:12px; margin-top:8px">
           Create Game Room
@@ -55,7 +69,7 @@ const currentUser = await getUser(currentUserName);
       <!-- Join Game -->
       <div class="card">
         <h2 class="card-title">Join Game</h2>
-        <p class="card-desc">Have a game ID or link? Enter it below to join as player 2.</p>
+        <p class="card-desc">Have a game ID or link? Enter it below to join.</p>
 
         <form id="join-form" style="margin-top:8px">
           <div class="form-group">
@@ -73,6 +87,20 @@ const currentUser = await getUser(currentUserName);
 </Layout>
 
 <script>
+  let selectedMode = "inperson";
+
+  const tabs = document.querySelectorAll(".mode-tab");
+  tabs.forEach(tab => {
+    tab.addEventListener("click", () => {
+      tabs.forEach(t => t.classList.remove("active"));
+      tab.classList.add("active");
+      selectedMode = (tab as HTMLElement).dataset.mode!;
+      // Hide any previous result when switching modes
+      const result = document.getElementById("created-result") as HTMLElement;
+      result.style.display = "none";
+    });
+  });
+
   const createBtn = document.getElementById("create-btn") as HTMLButtonElement;
   const createdResult = document.getElementById("created-result") as HTMLElement;
   const gameLink = document.getElementById("game-link") as HTMLInputElement;
@@ -86,6 +114,8 @@ const currentUser = await getUser(currentUserName);
 
     const res = await fetch("/api/game", {
       method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ mode: selectedMode }),
     });
 
     if (!res.ok) {
@@ -112,7 +142,6 @@ const currentUser = await getUser(currentUserName);
     setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000);
   });
 
-  // Join form
   const joinForm = document.getElementById("join-form") as HTMLFormElement;
   const joinInput = document.getElementById("join-input") as HTMLInputElement;
   const joinError = document.getElementById("join-error") as HTMLElement;
@@ -120,99 +149,73 @@ const currentUser = await getUser(currentUserName);
   joinForm.addEventListener("submit", (e) => {
     e.preventDefault();
     joinError.style.display = "none";
-
     let val = joinInput.value.trim();
     if (!val) return;
-
-    // Extract ID from URL if needed
     let id = val;
     try {
       const url = new URL(val);
       const parts = url.pathname.split("/").filter(Boolean);
-      if (parts.length >= 2 && parts[0] === "game") {
-        id = parts[1];
-      }
+      if (parts.length >= 2 && parts[0] === "game") id = parts[1];
     } catch {}
-
-    if (!id) {
-      joinError.textContent = "Invalid game ID";
-      joinError.style.display = "block";
-      return;
-    }
-
+    if (!id) { joinError.textContent = "Invalid game ID"; joinError.style.display = "block"; return; }
     location.href = `/game/${id}`;
   });
 </script>
 
 <style>
-  .play-page {
-    padding-bottom: 48px;
-  }
-
-  .play-grid {
-    display: grid;
-    grid-template-columns: 1fr 1fr;
-    gap: 24px;
-  }
+  .play-page { padding-bottom: 48px; }
 
-  .card-title {
-    font-family: 'Bebas Neue', sans-serif;
-    font-size: 1.5rem;
-    letter-spacing: 0.05em;
-    margin-bottom: 8px;
-  }
-
-  .card-desc {
-    color: var(--text-muted);
-    font-size: 13px;
-    margin-bottom: 0;
-    line-height: 1.6;
-  }
-
-  .created-link-box {
-    margin-bottom: 20px;
-  }
-
-  .link-row {
+  .mode-tabs {
     display: flex;
-    gap: 8px;
+    gap: 12px;
+    margin-bottom: 24px;
   }
 
-  .link-row input {
+  .mode-tab {
     flex: 1;
-    font-size: 12px;
+    background: var(--card);
+    border: 2px solid var(--border);
+    border-radius: 4px;
+    padding: 16px;
+    cursor: pointer;
+    text-align: left;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    transition: border-color 0.15s, background 0.15s;
+    font-family: 'DM Mono', monospace;
   }
 
-  .qr-section {
-    margin-bottom: 8px;
-  }
+  .mode-tab:hover { border-color: var(--border-light); }
 
-  .qr-box {
-    background: white;
-    border-radius: 4px;
-    display: inline-block;
-    padding: 12px;
+  .mode-tab.active {
+    border-color: var(--green);
+    background: rgba(129, 182, 76, 0.06);
   }
 
-  .qr-box img {
-    display: block;
-    width: 160px;
-    height: 160px;
-  }
+  .mode-icon { font-size: 20px; }
 
-  .error-msg {
-    color: var(--red);
-    font-size: 12px;
-    margin-bottom: 12px;
-    padding: 8px 12px;
-    background: rgba(224, 110, 110, 0.1);
-    border-radius: 3px;
-    border: 1px solid rgba(224, 110, 110, 0.2);
+  .mode-label {
+    font-family: 'Bebas Neue', sans-serif;
+    font-size: 1.3rem;
+    letter-spacing: 0.05em;
+    color: var(--text);
   }
 
-  @media (max-width: 640px) {
-    .play-grid {
-      grid-template-columns: 1fr;
-    }
+  .mode-desc {
+    font-size: 11px;
+    color: var(--text-muted);
   }
+
+  .play-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
+  .card-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.5rem; letter-spacing: 0.05em; margin-bottom: 8px; }
+  .card-desc { color: var(--text-muted); font-size: 13px; margin-bottom: 0; line-height: 1.6; }
+  .created-link-box { margin-bottom: 20px; }
+  .link-row { display: flex; gap: 8px; }
+  .link-row input { flex: 1; font-size: 12px; }
+  .qr-section { margin-bottom: 8px; }
+  .qr-box { background: white; border-radius: 4px; display: inline-block; padding: 12px; }
+  .qr-box img { display: block; width: 160px; height: 160px; }
+  .error-msg { color: var(--red); font-size: 12px; margin-bottom: 12px; padding: 8px 12px; background: rgba(224,110,110,0.1); border-radius: 3px; border: 1px solid rgba(224,110,110,0.2); }
+  @media (max-width: 640px) { .play-grid { grid-template-columns: 1fr; } .mode-tabs { flex-direction: column; } }
 </style>

+ 13 - 9
src/pages/profile/[name].astro

@@ -23,8 +23,10 @@ const [history, ratingHistory] = await Promise.all([
   getRatingHistory(decodedName),
 ]);
 
-const winRate = profileUser.games_played > 0
-  ? Math.round((profileUser.wins / profileUser.games_played) * 100)
+const totalWins = profileUser.wins + profileUser.wins_online;
+const totalGames = profileUser.games_played + profileUser.games_played_online;
+const winRate = totalGames > 0
+  ? Math.round((totalWins / totalGames) * 100)
   : 0;
 
 function timeAgo(iso: string): string {
@@ -67,21 +69,21 @@ const ratingTrend = ratingHistory.length >= 2
       <div class="profile-stats">
         <div class="stat-block">
           <span class="stat-num">{profileUser.rating}</span>
-          <span class="stat-label">Rating</span>
+          <span class="stat-label">IRL Rating</span>
+        </div>
+        <div class="stat-block">
+          <span class="stat-num" style="color:#7aabee">{profileUser.rating_online}</span>
+          <span class="stat-label">Online Rating</span>
         </div>
         <div class="stat-divider"></div>
         <div class="stat-block">
-          <span class="stat-num win">{profileUser.wins}</span>
+          <span class="stat-num win">{profileUser.wins + profileUser.wins_online}</span>
           <span class="stat-label">Wins</span>
         </div>
         <div class="stat-block">
-          <span class="stat-num loss">{profileUser.losses}</span>
+          <span class="stat-num loss">{profileUser.losses + profileUser.losses_online}</span>
           <span class="stat-label">Losses</span>
         </div>
-        <div class="stat-block">
-          <span class="stat-num">{profileUser.games_played}</span>
-          <span class="stat-label">Games</span>
-        </div>
         <div class="stat-divider"></div>
         <div class="stat-block">
           <span class="stat-num">{winRate}%</span>
@@ -130,6 +132,7 @@ const ratingTrend = ratingHistory.length >= 2
                 <th>Result</th>
                 <th>Opponent</th>
                 <th>Score</th>
+                <th>Mode</th>
                 <th>Date</th>
               </tr>
             </thead>
@@ -151,6 +154,7 @@ const ratingTrend = ratingHistory.length >= 2
                       <a href={`/profile/${encodeURIComponent(opponent)}`}>{opponent}</a>
                     </td>
                     <td class="muted">{myScore} — {oppScore}</td>
+                    <td><span class="dim" style="font-size:10px">{g.mode === "virtual" ? "🌐" : "🏓"}</span></td>
                     <td class="dim" style="font-size:11px">
                       {g.completed_at ? timeAgo(g.completed_at) : "—"}
                     </td>

+ 73 - 20
src/utils/db.ts

@@ -13,9 +13,13 @@ export async function init() {
     CREATE TABLE IF NOT EXISTS users (
       name TEXT PRIMARY KEY,
       rating INTEGER DEFAULT 1200,
+      rating_online INTEGER DEFAULT 1200,
       wins INTEGER DEFAULT 0,
       losses INTEGER DEFAULT 0,
       games_played INTEGER DEFAULT 0,
+      wins_online INTEGER DEFAULT 0,
+      losses_online INTEGER DEFAULT 0,
+      games_played_online INTEGER DEFAULT 0,
       created_at TEXT NOT NULL
     )
   `);
@@ -27,6 +31,7 @@ export async function init() {
       score1 INTEGER DEFAULT 0,
       score2 INTEGER DEFAULT 0,
       status TEXT DEFAULT 'waiting',
+      mode TEXT DEFAULT 'inperson',
       winner TEXT,
       created_at TEXT NOT NULL,
       completed_at TEXT
@@ -37,19 +42,38 @@ export async function init() {
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       player_name TEXT NOT NULL,
       rating INTEGER NOT NULL,
+      mode TEXT DEFAULT 'inperson',
       game_id TEXT NOT NULL,
       recorded_at TEXT NOT NULL
     )
   `);
+  // Migrations for existing DBs
+  const migrations = [
+    "ALTER TABLE users ADD COLUMN rating_online INTEGER DEFAULT 1200",
+    "ALTER TABLE users ADD COLUMN wins_online INTEGER DEFAULT 0",
+    "ALTER TABLE users ADD COLUMN losses_online INTEGER DEFAULT 0",
+    "ALTER TABLE users ADD COLUMN games_played_online INTEGER DEFAULT 0",
+    "ALTER TABLE games ADD COLUMN mode TEXT DEFAULT 'inperson'",
+    "ALTER TABLE rating_history ADD COLUMN mode TEXT DEFAULT 'inperson'",
+  ];
+  for (const sql of migrations) {
+    try { await client.execute(sql); } catch {}
+  }
   initialized = true;
 }
 
+export type GameMode = "inperson" | "virtual";
+
 export type User = {
   name: string;
   rating: number;
+  rating_online: number;
   wins: number;
   losses: number;
   games_played: number;
+  wins_online: number;
+  losses_online: number;
+  games_played_online: number;
   created_at: string;
 };
 
@@ -60,6 +84,7 @@ export type Game = {
   score1: number;
   score2: number;
   status: "waiting" | "active" | "complete";
+  mode: GameMode;
   winner: string | null;
   created_at: string;
   completed_at: string | null;
@@ -69,6 +94,7 @@ export type RatingHistory = {
   id: number;
   player_name: string;
   rating: number;
+  mode: GameMode;
   game_id: string;
   recorded_at: string;
 };
@@ -77,9 +103,13 @@ function rowToUser(row: Record<string, unknown>): User {
   return {
     name: row.name as string,
     rating: row.rating as number,
+    rating_online: (row.rating_online as number) ?? 1200,
     wins: row.wins as number,
     losses: row.losses as number,
     games_played: row.games_played as number,
+    wins_online: (row.wins_online as number) ?? 0,
+    losses_online: (row.losses_online as number) ?? 0,
+    games_played_online: (row.games_played_online as number) ?? 0,
     created_at: row.created_at as string,
   };
 }
@@ -92,6 +122,7 @@ function rowToGame(row: Record<string, unknown>): Game {
     score1: row.score1 as number,
     score2: row.score2 as number,
     status: row.status as Game["status"],
+    mode: (row.mode as GameMode) ?? "inperson",
     winner: (row.winner as string | null) ?? null,
     created_at: row.created_at as string,
     completed_at: (row.completed_at as string | null) ?? null,
@@ -110,8 +141,8 @@ export async function getUser(name: string): Promise<User | null> {
 export async function upsertUser(name: string): Promise<User> {
   const now = new Date().toISOString();
   await client.execute({
-    sql: `INSERT INTO users (name, rating, wins, losses, games_played, created_at)
-          VALUES (?, 1200, 0, 0, 0, ?)
+    sql: `INSERT INTO users (name, rating, rating_online, wins, losses, games_played, wins_online, losses_online, games_played_online, created_at)
+          VALUES (?, 1200, 1200, 0, 0, 0, 0, 0, 0, ?)
           ON CONFLICT(name) DO NOTHING`,
     args: [name, now],
   });
@@ -122,16 +153,29 @@ export async function updateUserStats(
   name: string,
   rating: number,
   won: boolean,
+  mode: GameMode,
 ) {
-  await client.execute({
-    sql: `UPDATE users SET
-            rating = ?,
-            wins = wins + ?,
-            losses = losses + ?,
-            games_played = games_played + 1
-          WHERE name = ?`,
-    args: [rating, won ? 1 : 0, won ? 0 : 1, name],
-  });
+  if (mode === "virtual") {
+    await client.execute({
+      sql: `UPDATE users SET
+              rating_online = ?,
+              wins_online = wins_online + ?,
+              losses_online = losses_online + ?,
+              games_played_online = games_played_online + 1
+            WHERE name = ?`,
+      args: [rating, won ? 1 : 0, won ? 0 : 1, name],
+    });
+  } else {
+    await client.execute({
+      sql: `UPDATE users SET
+              rating = ?,
+              wins = wins + ?,
+              losses = losses + ?,
+              games_played = games_played + 1
+            WHERE name = ?`,
+      args: [rating, won ? 1 : 0, won ? 0 : 1, name],
+    });
+  }
 }
 
 export async function getGame(id: string): Promise<Game | null> {
@@ -143,12 +187,12 @@ export async function getGame(id: string): Promise<Game | null> {
   return row ? rowToGame(row) : null;
 }
 
-export async function createGame(id: string, player1: string): Promise<Game> {
+export async function createGame(id: string, player1: string, mode: GameMode = "inperson"): Promise<Game> {
   const now = new Date().toISOString();
   await client.execute({
-    sql: `INSERT INTO games (id, player1, score1, score2, status, created_at)
-          VALUES (?, ?, 0, 0, 'waiting', ?)`,
-    args: [id, player1, now],
+    sql: `INSERT INTO games (id, player1, score1, score2, status, mode, created_at)
+          VALUES (?, ?, 0, 0, 'waiting', ?, ?)`,
+    args: [id, player1, mode, now],
   });
   return (await getGame(id))!;
 }
@@ -190,15 +234,23 @@ export async function getRecentGames(limit = 10): Promise<Game[]> {
   return (res.rows as Record<string, unknown>[]).map(rowToGame);
 }
 
-export async function getLeaderboard(limit = 20): Promise<User[]> {
+export async function getLeaderboard(limit = 20, mode: GameMode = "inperson"): Promise<User[]> {
+  const col = mode === "virtual" ? "rating_online" : "rating";
   const res = await client.execute({
-    sql: `SELECT * FROM users ORDER BY rating DESC LIMIT ?`,
+    sql: `SELECT * FROM users ORDER BY ${col} DESC LIMIT ?`,
     args: [limit],
   });
   return (res.rows as Record<string, unknown>[]).map(rowToUser);
 }
 
-export async function getRatingHistory(name: string): Promise<RatingHistory[]> {
+export async function getRatingHistory(name: string, mode?: GameMode): Promise<RatingHistory[]> {
+  if (mode) {
+    const res = await client.execute({
+      sql: `SELECT * FROM rating_history WHERE player_name = ? AND mode = ? ORDER BY recorded_at ASC`,
+      args: [name, mode],
+    });
+    return res.rows as unknown as RatingHistory[];
+  }
   const res = await client.execute({
     sql: `SELECT * FROM rating_history WHERE player_name = ? ORDER BY recorded_at ASC`,
     args: [name],
@@ -210,10 +262,11 @@ export async function addRatingHistory(
   playerName: string,
   rating: number,
   gameId: string,
+  mode: GameMode = "inperson",
 ) {
   await client.execute({
-    sql: `INSERT INTO rating_history (player_name, rating, game_id, recorded_at) VALUES (?, ?, ?, ?)`,
-    args: [playerName, rating, gameId, new Date().toISOString()],
+    sql: `INSERT INTO rating_history (player_name, rating, mode, game_id, recorded_at) VALUES (?, ?, ?, ?, ?)`,
+    args: [playerName, rating, mode, gameId, new Date().toISOString()],
   });
 }