Add shared multiplayer gear puzzle (Chamber of Motion)

Players open a no-login link and see live cursors and dragged gear
pieces synced in real time, backed by a small Workerman WebSocket
relay (src/Command/PuzzleRelayCommand.php) run alongside symfony
serve - no Docker/Mercure hub needed. The board renders the wall
artwork with a spare-gears tray, and a "Reset puzzle" button (visible
only to logged-in users, verified server-side via a signed token) lets
the DM wipe the shared state for everyone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-12 13:24:42 +02:00
co-authored by Claude Sonnet 5
parent 40dc30f950
commit 2ccbf4ca67
21 changed files with 3293 additions and 2394 deletions
+236
View File
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Puzzle\PuzzleResetTokenSigner;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Workerman\Connection\TcpConnection;
use Workerman\Timer;
use Workerman\Worker;
/**
* A small WebSocket relay that lets several browsers looking at the same
* puzzle room see each other's cursor and drag the same pieces around.
*
* Rooms live only in this process's memory (no database, no cache): if the
* relay restarts, every room resets. Run it separately from the web server,
* e.g. `symfony console app:puzzle-relay` next to `symfony serve`.
*/
#[AsCommand(
name: 'app:puzzle-relay',
description: 'Runs the WebSocket relay that syncs cursors and dragged pieces across the shared puzzle screens.',
)]
final class PuzzleRelayCommand extends Command
{
/**
* Clients ping every 15s (see puzzle_room_controller.js); a connection
* that's gone quiet for longer than this is presumed dead (closed tab,
* lost network, phone asleep) and gets dropped so it stops showing up
* as a ghost cursor/piece-holder for everyone else.
*/
private const HEARTBEAT_TIMEOUT_SECONDS = 45;
/** @var array<string, array<int, TcpConnection>> room => connection id => connection */
private array $connections = [];
/** @var array<string, array<int, array{clientId: string, name: string, color: string}>> room => connection id => presence */
private array $presence = [];
/** @var array<string, array<string, array{x: float, y: float}>> room => piece id => position */
private array $pieces = [];
public function __construct(
#[Autowire(env: 'PUZZLE_RELAY_LISTEN')]
private readonly string $listenAddress,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
private readonly PuzzleResetTokenSigner $resetTokenSigner,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln("Puzzle relay listening on ws://{$this->listenAddress} (Ctrl+C to stop)");
$logDir = $this->projectDir.'/var/log';
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
}
Worker::$logFile = $logDir.'/puzzle-relay.log';
Worker::$pidFile = $logDir.'/puzzle-relay.pid';
Worker::$stdoutFile = $logDir.'/puzzle-relay-stdout.log';
$worker = new Worker("websocket://{$this->listenAddress}");
$worker->onConnect = static function (TcpConnection $connection): void {
$connection->lastSeenAt = time();
};
$worker->onMessage = $this->onMessage(...);
$worker->onClose = $this->onClose(...);
$worker->onWorkerStart = function () use ($worker): void {
Timer::add(10, fn () => $this->reapDeadConnections($worker));
};
Worker::runAll();
return Command::SUCCESS;
}
private function onMessage(TcpConnection $connection, string $data): void
{
$message = json_decode($data, true);
if (!\is_array($message) || !\is_string($message['type'] ?? null)) {
return;
}
$connection->lastSeenAt = time();
match ($message['type']) {
'join' => $this->handleJoin($connection, $message),
'cursor' => $this->handleCursor($connection, $message),
'piece' => $this->handlePiece($connection, $message),
'reset' => $this->handleReset($connection, $message),
'ping' => null,
default => null,
};
}
private function reapDeadConnections(Worker $worker): void
{
$cutoff = time() - self::HEARTBEAT_TIMEOUT_SECONDS;
foreach ($worker->connections as $connection) {
if (($connection->lastSeenAt ?? 0) < $cutoff) {
$connection->close();
}
}
}
/** @param array<string, mixed> $message */
private function handleJoin(TcpConnection $connection, array $message): void
{
$room = $this->roomFrom($message);
if (null === $room) {
return;
}
$connection->room = $room;
$connection->clientId = (string) ($message['clientId'] ?? '');
$connection->name = mb_substr((string) ($message['name'] ?? 'Player'), 0, 40);
$connection->color = (string) ($message['color'] ?? '#8a8a8a');
$this->connections[$room][$connection->id] = $connection;
$this->presence[$room][$connection->id] = [
'clientId' => $connection->clientId,
'name' => $connection->name,
'color' => $connection->color,
];
$connection->send(json_encode([
'type' => 'state',
'pieces' => $this->pieces[$room] ?? new \stdClass(),
'clients' => array_values($this->presence[$room]),
], \JSON_THROW_ON_ERROR));
$this->broadcastPresence($room);
}
/** @param array<string, mixed> $message */
private function handleCursor(TcpConnection $connection, array $message): void
{
$room = $connection->room ?? null;
if (!\is_string($room)) {
return;
}
$this->broadcast($room, [
'type' => 'cursor',
'clientId' => $connection->clientId,
'name' => $connection->name,
'color' => $connection->color,
'x' => (float) ($message['x'] ?? 0),
'y' => (float) ($message['y'] ?? 0),
], except: $connection->id);
}
/** @param array<string, mixed> $message */
private function handlePiece(TcpConnection $connection, array $message): void
{
$room = $connection->room ?? null;
$id = $message['id'] ?? null;
if (!\is_string($room) || !\is_string($id)) {
return;
}
$x = (float) ($message['x'] ?? 0);
$y = (float) ($message['y'] ?? 0);
$this->pieces[$room][$id] = ['x' => $x, 'y' => $y];
$this->broadcast($room, [
'type' => 'piece',
'id' => $id,
'x' => $x,
'y' => $y,
'phase' => \in_array($message['phase'] ?? null, ['move', 'drop'], true) ? $message['phase'] : 'move',
'clientId' => $connection->clientId,
], except: $connection->id);
}
/** @param array<string, mixed> $message */
private function handleReset(TcpConnection $connection, array $message): void
{
$room = $connection->room ?? null;
$token = $message['resetToken'] ?? null;
if (!\is_string($room) || !\is_string($token) || !$this->resetTokenSigner->verify($room, $token)) {
return;
}
unset($this->pieces[$room]);
$this->broadcast($room, ['type' => 'reset']);
}
private function onClose(TcpConnection $connection): void
{
$room = $connection->room ?? null;
if (!\is_string($room)) {
return;
}
unset($this->connections[$room][$connection->id], $this->presence[$room][$connection->id]);
$this->broadcastPresence($room);
}
private function broadcastPresence(string $room): void
{
$this->broadcast($room, [
'type' => 'presence',
'clients' => array_values($this->presence[$room] ?? []),
]);
}
/** @param array<string, mixed> $payload */
private function broadcast(string $room, array $payload, ?int $except = null): void
{
$encoded = json_encode($payload, \JSON_THROW_ON_ERROR);
foreach ($this->connections[$room] ?? [] as $id => $connection) {
if ($id === $except) {
continue;
}
$connection->send($encoded);
}
}
/** @param array<string, mixed> $message */
private function roomFrom(array $message): ?string
{
$room = $message['room'] ?? null;
return \is_string($room) && '' !== $room ? $room : null;
}
}