fix(chess): send game updates to move sender + responsive mobile redesign
Some checks failed
Deploy to Production / test (push) Failing after 32s

Bun's ws.publish() excludes the sender, so the player making a move never
received the GAME_UPDATE with the new FEN — causing pieces to snap back.
Added ctx.send() alongside ctx.publish() for GAME_UPDATE and GAME_ENDED.

Also redesigned the panel for mobile: hamburger drawer sidebar, responsive
chess board sizing via ResizeObserver, stacked layouts on small screens,
and touch-friendly modals/controls across lobby and game pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-04-02 14:58:40 +02:00
parent 24211dca14
commit 9c4da51cfb
5 changed files with 166 additions and 89 deletions

View File

@@ -77,15 +77,19 @@ export function handleGameMessage(msg: GameWsClientMessage, ctx: WsContext): voi
}
const spectatorView = roomManager.getSpectatorView(msg.roomId);
ctx.publish(`room:${msg.roomId}`, JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView }));
const updateMsg = JSON.stringify({ type: "GAME_UPDATE", roomId: msg.roomId, state: spectatorView });
ctx.publish(`room:${msg.roomId}`, updateMsg);
ctx.send(updateMsg);
if (result.gameOver) {
ctx.publish(`room:${msg.roomId}`, JSON.stringify({
const endedMsg = JSON.stringify({
type: "GAME_ENDED",
roomId: msg.roomId,
winner: result.gameOver.winner,
reason: result.gameOver.reason,
}));
});
ctx.publish(`room:${msg.roomId}`, endedMsg);
ctx.send(endedMsg);
ctx.publish("lobby", JSON.stringify({ type: "ROOM_LIST_UPDATE", rooms: roomManager.listRooms() }));
}
break;

View File

@@ -13,8 +13,10 @@ import {
ChevronRight,
Gamepad2,
Trophy,
Menu,
X,
} from "lucide-react";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { cn } from "../lib/utils";
import type { AuthUser } from "../lib/useAuth";
@@ -54,6 +56,7 @@ export default function Layout({
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const navigate = useNavigate();
@@ -63,6 +66,11 @@ export default function Layout({
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
: null;
// Close mobile drawer on route change
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
function isActive(path: string): boolean {
if (path === "/admin" && location.pathname === "/admin") return true;
if (path === "/dashboard" && location.pathname === "/dashboard") return true;
@@ -70,25 +78,18 @@ export default function Layout({
return false;
}
return (
<div className="min-h-screen flex">
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
<div className="flex items-center h-16 px-4 border-b border-border">
<div className="font-display text-xl font-bold tracking-tight">
{collapsed ? "A" : "Aurora"}
</div>
</div>
function handleNav(path: string) {
navigate(path);
setMobileOpen(false);
}
const sidebarContent = (
<>
<nav className="flex-1 py-3 px-2 space-y-1 overflow-y-auto">
{navItems.map(({ path, label, icon: Icon }) => (
<button
key={path}
onClick={() => navigate(path)}
onClick={() => handleNav(path)}
className={cn(
"w-full flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
isActive(path)
@@ -97,13 +98,13 @@ export default function Layout({
)}
>
<Icon className={cn("w-5 h-5 shrink-0", isActive(path) && "text-primary")} />
{!collapsed && <span>{label}</span>}
{(!collapsed || mobileOpen) && <span>{label}</span>}
</button>
))}
</nav>
<div className="border-t border-border p-3 space-y-2">
{!collapsed && (
{(!collapsed || mobileOpen) && (
<div className="flex items-center gap-3 px-2 py-1.5">
{avatarUrl ? (
<img src={avatarUrl} alt={user.username} className="w-8 h-8 rounded-full" />
@@ -117,28 +118,83 @@ export default function Layout({
</div>
</div>
)}
<div className={cn("flex", collapsed ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<div className={cn("flex", collapsed && !mobileOpen ? "flex-col items-center gap-2" : "items-center justify-between px-2")}>
<button
onClick={logout}
className="inline-flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-text-tertiary hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Sign out"
>
<LogOut className="w-4 h-4" />
{!collapsed && <span>Sign out</span>}
{(!collapsed || mobileOpen) && <span>Sign out</span>}
</button>
{/* Collapse toggle only on desktop */}
<button
onClick={() => setCollapsed((c) => !c)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
className="hidden md:block p-1.5 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
</>
);
return (
<div className="min-h-screen flex">
{/* Mobile header bar */}
<div className="fixed top-0 left-0 right-0 z-40 flex items-center h-14 px-4 bg-background border-b border-border md:hidden">
<button
onClick={() => setMobileOpen(true)}
className="p-2 -ml-2 rounded-md text-text-tertiary hover:text-foreground hover:bg-primary/10 transition-colors"
>
<Menu className="w-5 h-5" />
</button>
<div className="font-display text-lg font-bold tracking-tight ml-3">Aurora</div>
</div>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-50 bg-black/50 md:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar - mobile drawer + desktop fixed */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col bg-background border-r border-border transition-all duration-200",
// Mobile: off-screen drawer, shown when mobileOpen
"w-60 -translate-x-full md:translate-x-0",
mobileOpen && "translate-x-0",
// Desktop: respect collapsed state
!mobileOpen && collapsed && "md:w-16"
)}
>
<div className="flex items-center justify-between h-14 md:h-16 px-4 border-b border-border">
<div className="font-display text-xl font-bold tracking-tight">
{collapsed && !mobileOpen ? "A" : "Aurora"}
</div>
{/* Close button on mobile */}
<button
onClick={() => setMobileOpen(false)}
className="p-1.5 rounded-md text-text-tertiary hover:text-foreground md:hidden"
>
<X className="w-5 h-5" />
</button>
</div>
{sidebarContent}
</aside>
<main className={cn("flex-1 transition-all duration-200", collapsed ? "ml-16" : "ml-60")}>
<div className="max-w-[1600px] mx-auto px-6 py-8">
<main className={cn(
"flex-1 transition-all duration-200",
// Mobile: no margin, pad for top header bar
"mt-14 md:mt-0",
collapsed ? "md:ml-16" : "md:ml-60"
)}>
<div className="max-w-[1600px] mx-auto px-4 py-6 md:px-6 md:py-8">
{children}
</div>
</main>

View File

@@ -49,20 +49,20 @@ export function GameLobby() {
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
<div className="min-w-0">
<h1 className="font-display text-lg font-semibold">Games</h1>
<p className="text-sm text-text-tertiary">Browse and create game rooms</p>
<p className="text-sm text-text-tertiary hidden sm:block">Browse and create game rooms</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors"
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 transition-colors shrink-0"
>
+ Create Room
</button>
</div>
<div className="flex gap-2 mb-4">
<div className="flex gap-2 mb-4 overflow-x-auto pb-1">
<button
onClick={() => setFilter(null)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
@@ -98,11 +98,11 @@ export function GameLobby() {
{activeRooms.map(room => {
const plugin = gameUIRegistry.get(room.gameSlug);
return (
<div key={room.id} className="flex items-center justify-between px-5 py-3 hover:bg-raised/40 transition-colors">
<div className="flex items-center gap-3">
<span className="text-lg">{plugin?.icon ?? "🎮"}</span>
<div>
<div className="text-sm font-medium">{room.gameName}</div>
<div key={room.id} className="flex items-center justify-between gap-3 px-4 py-3 md:px-5 hover:bg-raised/40 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<span className="text-lg shrink-0">{plugin?.icon ?? "🎮"}</span>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{room.gameName}</div>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
room.status === "waiting"
@@ -118,7 +118,7 @@ export function GameLobby() {
</div>
<button
onClick={() => navigate(`/${room.gameSlug}/${room.id}`)}
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors shrink-0 ${
room.status === "waiting"
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-card border border-border text-text-tertiary hover:text-foreground"
@@ -134,8 +134,8 @@ export function GameLobby() {
</div>
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
<div className="bg-card border border-border rounded-lg p-6 w-full max-w-sm" onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50" onClick={() => setShowCreate(false)}>
<div className="bg-card border border-border rounded-t-xl sm:rounded-lg p-6 w-full sm:max-w-sm" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-base font-semibold mb-4">Create a Room</h2>
<div className="space-y-2">
{gameTypes.map(g => (

View File

@@ -50,11 +50,11 @@ export function GameRoom({ userId }: { userId: string }) {
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-xl">{plugin.icon}</span>
<div>
<h1 className="font-display text-base font-semibold">{plugin.name}</h1>
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
<div className="flex items-center gap-3 min-w-0">
<span className="text-xl shrink-0">{plugin.icon}</span>
<div className="min-w-0">
<h1 className="font-display text-base font-semibold truncate">{plugin.name}</h1>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
roomStatus === "waiting" ? "bg-warning/15 text-warning"
@@ -70,7 +70,7 @@ export function GameRoom({ userId }: { userId: string }) {
</div>
<button
onClick={() => { leaveRoom(); navigate("/games"); }}
className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors"
className="rounded-md px-3 py-1.5 text-sm font-medium bg-card border border-border text-text-tertiary hover:text-foreground transition-colors shrink-0"
>
Leave
</button>
@@ -94,12 +94,13 @@ export function GameRoom({ userId }: { userId: string }) {
)}
{roomStatus === "waiting" && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<div className="bg-card rounded-lg border border-border p-5 md:p-8 text-center">
<div className="text-sm text-text-tertiary mb-2">
Waiting for players ({players.length}/2)
</div>
<div className="text-xs text-text-disabled">
Share this URL to invite: <span className="font-mono bg-surface px-2 py-0.5 rounded select-all">{window.location.href}</span>
Share this URL to invite:
<span className="block mt-1 font-mono bg-surface px-2 py-1 rounded select-all text-[11px] break-all">{window.location.href}</span>
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useRef, useEffect } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import type { GameUIProps } from "../registry";
@@ -15,6 +15,22 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
const chess = state as ChessState;
const [promotionFrom, setPromotionFrom] = useState<string | null>(null);
const [promotionTo, setPromotionTo] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [boardWidth, setBoardWidth] = useState(400);
// Responsive board sizing
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 400;
// Cap board at 400px, floor at 280px
setBoardWidth(Math.max(280, Math.min(400, width)));
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const game = useMemo(() => {
if (!chess?.fen) return null;
@@ -89,7 +105,6 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
// Highlight king in check
const customSquareStyles: Record<string, React.CSSProperties> = {};
if (game.inCheck()) {
// Find the king square of the player in check
const board = game.board();
const kingColor = game.turn();
for (let r = 0; r < 8; r++) {
@@ -108,8 +123,9 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
}
return (
<div className="flex gap-4">
<div>
<div className="flex flex-col md:flex-row gap-4">
{/* Board column */}
<div ref={containerRef} className="w-full max-w-[400px]">
{/* Opponent info */}
<div className="flex items-center gap-2 mb-2">
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
@@ -126,7 +142,7 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
onPromotionPieceSelect={handlePromotion}
boardOrientation={boardOrientation}
isDraggablePiece={isDraggablePiece}
boardWidth={400}
boardWidth={boardWidth}
showPromotionDialog={promotionFrom !== null}
promotionToSquare={promotionTo as any}
animationDuration={200}
@@ -153,11 +169,11 @@ export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-3 min-w-[180px]">
{/* Sidebar - stacks below on mobile */}
<div className="flex flex-col gap-3 w-full md:w-auto md:min-w-[180px]">
<div className="bg-card rounded-lg border border-border">
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
<div className="px-4 py-2 max-h-64 overflow-y-auto">
<div className="px-4 py-2 max-h-48 md:max-h-64 overflow-y-auto">
{chess.moveHistory.length === 0 ? (
<div className="text-xs text-text-disabled">No moves yet</div>
) : (