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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dmtools",
"runtimeExecutable": "symfony",
"runtimeArgs": ["serve", "--no-tls"],
"port": 8000
}
]
}
+11
View File
@@ -59,3 +59,14 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###> symfony/mailer ###
MAILER_DSN=null://null
###< symfony/mailer ###
# Puzzle relay (src/Command/PuzzleRelayCommand.php): a small WebSocket server,
# run separately with `symfony console app:puzzle-relay` (or `php bin/console
# app:puzzle-relay`) alongside symfony serve, that syncs cursors and dragged
# pieces across the shared puzzle screens. No Symfony/Mercure component covers
# this (Mercure needs its own hub process/container); Workerman gives us a
# plain-PHP WebSocket server instead.
# PUZZLE_RELAY_LISTEN is the address the relay process binds to.
PUZZLE_RELAY_LISTEN=0.0.0.0:8091
# PUZZLE_RELAY_PUBLIC_URL is the address browsers connect to.
PUZZLE_RELAY_PUBLIC_URL=ws://127.0.0.1:8091
@@ -0,0 +1,270 @@
import { Controller } from '@hotwired/stimulus';
/*
* The shared, no-login puzzle board. Connects to the WebSocket relay
* (src/Command/PuzzleRelayCommand.php) so every open tab on this room sees
* the same piece positions and each other's cursors, live.
*
* <div data-controller="puzzle-room"
* data-puzzle-room-room-value="chamber-of-motion"
* data-puzzle-room-relay-url-value="ws://127.0.0.1:8091">
* <form data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">...</form>
* <div data-puzzle-room-target="board" hidden>
* <div data-puzzle-room-target="piece" data-piece-id="a"
* data-action="pointerdown->puzzle-room#startDrag">...</div>
* </div>
* </div>
*
* Room state (piece positions, who's online) lives only in the relay
* process's memory - there is no database or cache behind this.
*/
const CLIENT_KEY = 'dmtools.puzzleClient';
const CURSOR_THROTTLE_MS = 40;
const PIECE_THROTTLE_MS = 40;
const RECONNECT_DELAY_MS = 2000;
const HEARTBEAT_INTERVAL_MS = 15000;
export default class extends Controller {
static targets = ['board', 'piece', 'joinForm', 'nameInput', 'status'];
static values = { room: String, relayUrl: String, resetToken: String };
connect() {
this.cursorEls = new Map();
this.pieceEls = new Map();
this.defaultPositions = new Map();
this.pieceTargets.forEach((el) => {
this.pieceEls.set(el.dataset.pieceId, el);
this.defaultPositions.set(el.dataset.pieceId, { left: el.style.left, top: el.style.top });
});
this.dragging = null;
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onBoardPointerMove = this.onBoardPointerMove.bind(this);
this.sendCursor = throttle((x, y) => this.send({ type: 'cursor', room: this.roomValue, x, y }), CURSOR_THROTTLE_MS);
this.sendPieceMove = throttle((id, x, y) => this.send({ type: 'piece', room: this.roomValue, id, x, y, phase: 'move' }), PIECE_THROTTLE_MS);
this.client = this.loadClient();
if (this.client) {
this.startSession();
}
}
disconnect() {
clearInterval(this.heartbeatTimer);
this.socket?.close();
clearTimeout(this.reconnectTimer);
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
}
join(event) {
event.preventDefault();
const name = this.nameInputTarget.value.trim();
if (!name) return;
this.client = { clientId: crypto.randomUUID(), name: name.slice(0, 40), color: randomColor() };
try {
sessionStorage.setItem(CLIENT_KEY, JSON.stringify(this.client));
} catch {
/* private mode: identity just won't survive a reload */
}
this.startSession();
}
loadClient() {
try {
const raw = sessionStorage.getItem(CLIENT_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
startSession() {
this.joinFormTarget.hidden = true;
this.boardTarget.hidden = false;
this.boardTarget.addEventListener('pointermove', this.onBoardPointerMove);
this.connectSocket();
}
connectSocket() {
this.setStatus('Connecting…');
const socket = new WebSocket(this.relayUrlValue);
this.socket = socket;
socket.addEventListener('open', () => {
this.send({
type: 'join',
room: this.roomValue,
clientId: this.client.clientId,
name: this.client.name,
color: this.client.color,
});
this.heartbeatTimer = setInterval(() => this.send({ type: 'ping' }), HEARTBEAT_INTERVAL_MS);
});
socket.addEventListener('message', (event) => this.onMessage(event));
socket.addEventListener('close', () => {
clearInterval(this.heartbeatTimer);
this.setStatus('Disconnected — retrying…');
this.reconnectTimer = setTimeout(() => this.connectSocket(), RECONNECT_DELAY_MS);
});
socket.addEventListener('error', () => socket.close());
}
setStatus(text) {
if (this.hasStatusTarget) this.statusTarget.textContent = text;
}
send(payload) {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(payload));
}
}
onMessage(event) {
let message;
try {
message = JSON.parse(event.data);
} catch {
return;
}
switch (message.type) {
case 'state':
Object.entries(message.pieces || {}).forEach(([id, pos]) => this.placePiece(id, pos.x, pos.y));
this.renderPresence(message.clients || []);
break;
case 'cursor':
if (message.clientId !== this.client.clientId) this.renderCursor(message);
break;
case 'piece':
if (message.clientId !== this.client.clientId) this.placePiece(message.id, message.x, message.y);
break;
case 'presence':
this.renderPresence(message.clients || []);
break;
case 'reset':
this.resetPieces();
break;
}
}
reset() {
if (!this.resetTokenValue) return;
if (!window.confirm('Reset the puzzle? This puts every gear back in the tray for everyone in this room.')) return;
this.send({ type: 'reset', room: this.roomValue, resetToken: this.resetTokenValue });
}
resetPieces() {
this.dragging = null;
for (const [id, el] of this.pieceEls) {
const pos = this.defaultPositions.get(id);
if (!pos) continue;
el.style.left = pos.left;
el.style.top = pos.top;
}
}
renderPresence(clients) {
const seen = new Set(clients.map((c) => c.clientId));
for (const [clientId, el] of this.cursorEls) {
if (!seen.has(clientId)) {
el.remove();
this.cursorEls.delete(clientId);
}
}
const others = clients.filter((c) => c.clientId !== this.client.clientId).length;
this.setStatus(others > 0 ? `Connected — ${others} other${others === 1 ? '' : 's'} here` : "Connected — you're the only one here");
}
renderCursor(message) {
let el = this.cursorEls.get(message.clientId);
if (!el) {
el = document.createElement('div');
el.className = 'puzzle-cursor';
el.innerHTML = '<span class="puzzle-cursor-dot"></span><span class="puzzle-cursor-label"></span>';
el.querySelector('.puzzle-cursor-dot').style.background = message.color;
el.querySelector('.puzzle-cursor-label').style.background = message.color;
el.querySelector('.puzzle-cursor-label').textContent = message.name;
this.boardTarget.appendChild(el);
this.cursorEls.set(message.clientId, el);
}
el.style.left = `${message.x * 100}%`;
el.style.top = `${message.y * 100}%`;
}
placePiece(id, x, y) {
const el = this.pieceEls.get(id);
if (!el || this.dragging?.id === id) return;
el.style.left = `${x * 100}%`;
el.style.top = `${y * 100}%`;
}
onBoardPointerMove(event) {
const { x, y } = this.relativePosition(event);
this.sendCursor(x, y);
}
relativePosition(event) {
const rect = this.boardTarget.getBoundingClientRect();
return {
x: clamp((event.clientX - rect.left) / rect.width, 0, 1),
y: clamp((event.clientY - rect.top) / rect.height, 0, 1),
};
}
startDrag(event) {
const el = event.currentTarget;
el.setPointerCapture(event.pointerId);
this.dragging = { id: el.dataset.pieceId, el };
el.classList.add('is-dragging');
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
}
onPointerMove(event) {
if (!this.dragging) return;
const { x, y } = this.relativePosition(event);
this.dragging.el.style.left = `${x * 100}%`;
this.dragging.el.style.top = `${y * 100}%`;
this.sendPieceMove(this.dragging.id, x, y);
}
onPointerUp(event) {
if (!this.dragging) return;
const { x, y } = this.relativePosition(event);
this.send({ type: 'piece', room: this.roomValue, id: this.dragging.id, x, y, phase: 'drop' });
this.dragging.el.classList.remove('is-dragging');
this.dragging = null;
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
}
}
function throttle(fn, ms) {
let last = 0;
let timer = null;
return (...args) => {
const now = Date.now();
const remaining = ms - (now - last);
clearTimeout(timer);
if (remaining <= 0) {
last = now;
fn(...args);
} else {
timer = setTimeout(() => {
last = Date.now();
fn(...args);
}, remaining);
}
};
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function randomColor() {
return `hsl(${Math.floor(Math.random() * 360)}, 70%, 55%)`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

+2 -1
View File
@@ -44,7 +44,8 @@
"symfony/web-link": "8.1.*",
"symfony/yaml": "8.1.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"twig/twig": "^2.12|^3.0",
"workerman/workerman": "^5.2"
},
"config": {
"allow-plugins": {
Generated
+2510 -2393
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@ security:
# Note: Only the *first* matching rule is applied
access_control:
- { path: ^/login$, roles: PUBLIC_ACCESS }
- { path: ^/puzzle/, roles: PUBLIC_ACCESS }
- { path: ^/, roles: IS_AUTHENTICATED_FULLY }
role_hierarchy:
+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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Puzzle\PuzzleResetTokenSigner;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Shared, no-login puzzle screens: everyone with the link sees the same
* board, the same pieces, and each other's cursors, synced over the
* WebSocket relay in {@see \App\Command\PuzzleRelayCommand}.
*/
final class PuzzleController extends AbstractController
{
public function __construct(
#[Autowire(env: 'PUZZLE_RELAY_PUBLIC_URL')]
private readonly string $relayPublicUrl,
private readonly PuzzleResetTokenSigner $resetTokenSigner,
) {
}
#[Route('/puzzle/{room}', name: 'app_puzzle_room', requirements: ['room' => '[a-z0-9-]+'], methods: ['GET'])]
public function room(string $room): Response
{
return $this->render('puzzle/room.html.twig', [
'room' => $room,
'relayPublicUrl' => $this->relayPublicUrl,
// Only a logged-in user gets a token that lets them reset the shared
// board; anonymous players can play but not wipe it for everyone.
'resetToken' => $this->getUser() ? $this->resetTokenSigner->sign($room) : null,
]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Puzzle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Signs/verifies the token that lets a client reset a shared puzzle room.
* Used by both {@see \App\Controller\PuzzleController} (issues it to logged-in
* users) and {@see \App\Command\PuzzleRelayCommand} (a public WebSocket server
* outside Symfony's session/security system, so it must check the signature
* itself rather than trusting the client).
*/
final class PuzzleResetTokenSigner
{
public function __construct(
#[Autowire(env: 'APP_SECRET')]
private readonly string $secret,
) {
}
public function sign(string $room): string
{
return hash_hmac('sha256', $room, $this->secret);
}
public function verify(string $room, string $token): bool
{
return hash_equals($this->sign($room), $token);
}
}
+181
View File
@@ -0,0 +1,181 @@
{% extends 'base.html.twig' %}
{% block title %}Puzzle: {{ room }} · dmtools{% endblock %}
{% block navbar %}{% endblock %}
{% block stylesheets %}
<style>
.puzzle-join {
max-width: 320px;
margin: 4rem auto;
}
.puzzle-fullbleed {
position: relative;
left: 50%;
width: 100vw;
margin-left: -50vw;
padding: 0 1rem;
}
.puzzle-board {
position: relative;
width: 100%;
aspect-ratio: 1200 / 485;
background: #1b1f24;
border-radius: 0.5rem;
overflow: hidden;
touch-action: none;
user-select: none;
}
.puzzle-tray {
position: absolute;
left: 0;
top: 0;
width: 39.4%;
height: 100%;
background: linear-gradient(180deg, #2a2f37, #1b1f24);
border-right: 3px solid rgba(255, 255, 255, .08);
box-shadow: inset 0 0 30px rgba(0, 0, 0, .5);
}
.puzzle-tray-label {
position: absolute;
top: 0.3rem;
left: 0;
width: 100%;
text-align: center;
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255, 255, 255, .45);
}
.puzzle-wall {
position: absolute;
left: 39.4%;
top: 0;
width: 60.6%;
aspect-ratio: 1536 / 1024;
background: url('{{ asset('image/puzzle/wall.png') }}') center / cover no-repeat;
box-shadow: 0 0 0 3px rgba(0, 0, 0, .4), 0 10px 30px rgba(0, 0, 0, .5);
}
.puzzle-piece {
position: absolute;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.puzzle-piece img {
display: block;
width: 100%;
height: auto;
pointer-events: none;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, .5));
}
.puzzle-piece.is-dragging {
cursor: grabbing;
z-index: 5;
}
.puzzle-piece.is-dragging img {
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, .65));
}
.puzzle-cursor {
position: absolute;
pointer-events: none;
transform: translate(-2px, -2px);
z-index: 10;
}
.puzzle-cursor-dot {
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
}
.puzzle-cursor-label {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
border-radius: 3px;
font-size: .7rem;
color: #fff;
white-space: nowrap;
}
.puzzle-status {
opacity: .7;
font-size: .85rem;
}
</style>
{% endblock %}
{% block body %}
<div data-controller="puzzle-room"
data-puzzle-room-room-value="{{ room }}"
data-puzzle-room-relay-url-value="{{ relayPublicUrl }}"
data-puzzle-room-reset-token-value="{{ resetToken }}">
<form class="puzzle-join card p-4" data-puzzle-room-target="joinForm" data-action="submit->puzzle-room#join">
<h1 class="h4 mb-3">Join the puzzle</h1>
<label class="form-label" for="puzzle-name">Your name</label>
<input class="form-control mb-3" id="puzzle-name" type="text" maxlength="40" required
data-puzzle-room-target="nameInput" placeholder="e.g. Frank" autofocus>
<button class="btn btn-primary w-100" type="submit">Join</button>
</form>
<div class="puzzle-fullbleed">
<div class="mb-2 d-flex justify-content-between align-items-center">
<h1 class="h5 mb-0">Puzzle room: {{ room }}</h1>
<div class="d-flex align-items-center gap-3">
<span class="puzzle-status" data-puzzle-room-target="status">Connecting…</span>
{% if resetToken %}
<button type="button" class="btn btn-outline-secondary btn-sm" data-action="puzzle-room#reset">Reset puzzle</button>
{% endif %}
</div>
</div>
<div class="puzzle-board" hidden data-puzzle-room-target="board">
<div class="puzzle-tray">
<span class="puzzle-tray-label">Spare gears</span>
</div>
<div class="puzzle-wall"></div>
{# Gear sizes are % of board width. The bronze pegboard panel in wall.png
is ~49.5% of the wall image's own width, and the wall image itself is
60.6% of the board width (the rest is the 3-column tray on the left) -
so the biggest gear (F) is a third of the panel's actual on-screen
size, times 1.25 (bumped up 25% on request). Packed tightly (minimal
gaps/padding) so the tray stays as narrow and short as possible,
leaving the wall panel as big as it can be while still fitting one
screen height. #}
{% for piece in [
{ id: 'f', size: 12.49, x: 0.0657, y: 0.2582 },
{ id: 'e', size: 9.78, x: 0.197, y: 0.2582 },
{ id: 'd', size: 8.53, x: 0.3283, y: 0.2582 },
{ id: 'i', size: 7.38, x: 0.0657, y: 0.5393 },
{ id: 'c', size: 6.84, x: 0.197, y: 0.5393 },
{ id: 'h', size: 6.66, x: 0.3283, y: 0.5393 },
{ id: 'b', size: 5.31, x: 0.0657, y: 0.7339 },
{ id: 'g', size: 5.25, x: 0.197, y: 0.7339 },
{ id: 'a', size: 4.31, x: 0.3283, y: 0.7339 },
] %}
<div class="puzzle-piece" data-puzzle-room-target="piece" data-piece-id="{{ piece.id }}"
data-action="pointerdown->puzzle-room#startDrag"
style="left: {{ (piece.x * 100) }}%; top: {{ (piece.y * 100) }}%; width: {{ piece.size }}%;">
<img src="{{ asset('image/puzzle/gear-' ~ piece.id ~ '.png') }}" alt="Gear {{ piece.id|upper }}" draggable="false">
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}