]> git.cworth.org Git - lmno.games/commitdiff
Add an implementation of Lines of Action master
authorCarl Worth <cworth@cworth.org>
Thu, 12 Mar 2026 04:57:03 +0000 (21:57 -0700)
committerCarl Worth <cworth@cworth.org>
Thu, 12 Mar 2026 04:57:03 +0000 (21:57 -0700)
This comes from a Claude-assisted port of the Lines of Action
implementation I had made originally in C which can be found here:

https://git.cworth.org/git?p=loa

index.html
loa/index.html [new file with mode: 0644]
loa/loa-board.js [new file with mode: 0644]
loa/loa-client.js [new file with mode: 0644]
loa/loa-view.js [new file with mode: 0644]
loa/loa.css [new file with mode: 0644]

index 81a6b199e619ffd71b39dc0158b189797224a3f8..acace473a61171098c41611c7d7918dc91b619e2 100644 (file)
@@ -77,6 +77,9 @@
         <li>
           <a href="scribe">Scribe</a>
         </li>
+        <li>
+          <a href="loa">Lines of Action</a>
+        </li>
       </ul>
 
     </div>
diff --git a/loa/index.html b/loa/index.html
new file mode 100644 (file)
index 0000000..5b577d4
--- /dev/null
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>Lines of Action</title>
+
+    <link rel="stylesheet" href="/reset.css" type="text/css" />
+    <link rel="stylesheet" href="/style.css" type="text/css" />
+
+    <script src="/lmno.js"></script>
+  </head>
+  <body>
+
+    <div id="page">
+
+      <h1>Lines of Action</h1>
+
+      <p>
+        Connect all of your pieces into a single group. Move a piece
+        in a straight line (row, column, or diagonal) exactly as many
+        squares as there are pieces (of either color) along that line.
+        You may jump your own pieces but not your opponent's, and you
+        may capture by landing on an opponent's piece.
+      </p>
+
+      <div id="message-area">
+      </div>
+
+      <form onsubmit="lmno_new('loa'); return false;">
+        <button type="submit">
+          Host a new game
+        </button>
+      </form>
+
+      <form onsubmit="lmno_join(this); return false">
+
+        <div class="form-field large">
+          <label for="id">Or join an existing game:</label>
+          <input type="text" id="id" maxlength="4"
+                 placeholder="Enter a 4-letter Game Code"
+                 oninput="this.value = this.value.toUpperCase()"
+                 autocomplete="off"
+                 required>
+        </div>
+      </form>
+
+    </div>
+
+  </body>
+</html>
diff --git a/loa/loa-board.js b/loa/loa-board.js
new file mode 100644 (file)
index 0000000..dd135cb
--- /dev/null
@@ -0,0 +1,244 @@
+/* Lines of Action - shared game logic (server and client)
+ *
+ * In the browser this file is loaded via <script> so all symbols
+ * become globals.  In Node the conditional module.exports at the
+ * bottom makes them available to require().
+ */
+
+const BOARD_SIZE = 8;
+const DIAG_ARRAY_SIZE = 2 * BOARD_SIZE - 1;
+
+const CELL_EMPTY = 2;
+const CELL_BLACK = 0;
+const CELL_WHITE = 1;
+
+const PLAYER_BLACK = 0;
+const PLAYER_WHITE = 1;
+
+class LoaBoard {
+    constructor() {
+        this.cells = [];
+        this.numPieces = [0, 0];
+        this.rowPieces = new Array(BOARD_SIZE).fill(0);
+        this.colPieces = new Array(BOARD_SIZE).fill(0);
+        this.diagGravePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+        this.diagAcutePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+        this.player = PLAYER_BLACK;
+        this.init();
+    }
+
+    init() {
+        for (let x = 0; x < BOARD_SIZE; x++) {
+            this.cells[x] = [];
+            for (let y = 0; y < BOARD_SIZE; y++) {
+                this.cells[x][y] = CELL_EMPTY;
+            }
+        }
+        this.numPieces[0] = 0;
+        this.numPieces[1] = 0;
+        this.rowPieces.fill(0);
+        this.colPieces.fill(0);
+        this.diagGravePieces.fill(0);
+        this.diagAcutePieces.fill(0);
+
+        for (let i = 1; i < BOARD_SIZE - 1; i++) {
+            this._addPiece(i, 0, CELL_BLACK);
+            this._addPiece(i, BOARD_SIZE - 1, CELL_BLACK);
+            this._addPiece(0, i, CELL_WHITE);
+            this._addPiece(BOARD_SIZE - 1, i, CELL_WHITE);
+        }
+
+        this.player = PLAYER_BLACK;
+    }
+
+    _graveIndex(x, y) { return x - y + BOARD_SIZE - 1; }
+    _acuteIndex(x, y) { return x + y; }
+
+    _addPiece(x, y, cell) {
+        this.colPieces[x]++;
+        this.rowPieces[y]++;
+        this.diagGravePieces[this._graveIndex(x, y)]++;
+        this.diagAcutePieces[this._acuteIndex(x, y)]++;
+        this.numPieces[cell]++;
+        this.cells[x][y] = cell;
+    }
+
+    _removePiece(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return CELL_EMPTY;
+        this.colPieces[x]--;
+        this.rowPieces[y]--;
+        this.diagGravePieces[this._graveIndex(x, y)]--;
+        this.diagAcutePieces[this._acuteIndex(x, y)]--;
+        this.numPieces[cell]--;
+        this.cells[x][y] = CELL_EMPTY;
+        return cell;
+    }
+
+    _groupSize(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return 0;
+        const visited = new Set();
+        return this._groupSizeRecursive(x, y, cell, visited);
+    }
+
+    _groupSizeRecursive(x, y, cell, visited) {
+        if (x < 0 || y < 0 || x >= BOARD_SIZE || y >= BOARD_SIZE) return 0;
+        const key = x * BOARD_SIZE + y;
+        if (visited.has(key)) return 0;
+        visited.add(key);
+        if (this.cells[x][y] !== cell) return 0;
+        let count = 1;
+        for (let dx = -1; dx <= 1; dx++) {
+            for (let dy = -1; dy <= 1; dy++) {
+                if (dx === 0 && dy === 0) continue;
+                count += this._groupSizeRecursive(x + dx, y + dy, cell, visited);
+            }
+        }
+        return count;
+    }
+
+    isWon(x, y) {
+        const cell = this.cells[x][y];
+        if (cell === CELL_EMPTY) return false;
+        return this._groupSize(x, y) === this.numPieces[cell];
+    }
+
+    /* Check for a winner after a move to (x2, y2).
+     *
+     * In LOA a capture can connect the opponent's remaining pieces,
+     * so we must check both players.  If both are connected (rare),
+     * the player who just moved wins.
+     *
+     * Returns the winning player constant (PLAYER_BLACK / PLAYER_WHITE)
+     * or null if no winner yet.
+     */
+    checkWinner(x2, y2) {
+        /* The mover's piece is at (x2, y2).  board.player has already
+         * been toggled, so the mover is the *other* player. */
+        const mover = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+        const opponent = this.player;
+
+        /* Did the mover win? */
+        if (this.isWon(x2, y2))
+            return mover;
+
+        /* Did the capture connect all of the opponent's pieces? */
+        if (this.numPieces[opponent] > 0) {
+            for (let x = 0; x < BOARD_SIZE; x++) {
+                for (let y = 0; y < BOARD_SIZE; y++) {
+                    if (this.cells[x][y] === opponent) {
+                        if (this.isWon(x, y))
+                            return opponent;
+                        /* Only need to check one opponent piece --
+                         * if it's not in a group equal to numPieces
+                         * then no opponent piece is. */
+                        return null;
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    moveLegal(x1, y1, x2, y2) {
+        if (x1 < 0 || y1 < 0 || x1 >= BOARD_SIZE || y1 >= BOARD_SIZE ||
+            x2 < 0 || y2 < 0 || x2 >= BOARD_SIZE || y2 >= BOARD_SIZE) {
+            return { legal: false, error: "Invalid coordinates (not on board)" };
+        }
+        if (this.cells[x1][y1] === CELL_EMPTY) {
+            return { legal: false, error: "There is no piece there to move" };
+        }
+        if (this.cells[x1][y1] !== this.player) {
+            return { legal: false, error: "You cannot move your opponent's piece" };
+        }
+        if (this.cells[x2][y2] === this.cells[x1][y1]) {
+            return { legal: false, error: "You cannot capture your own piece" };
+        }
+
+        const dx = x2 - x1;
+        const dy = y2 - y1;
+
+        if (dx === 0) {
+            if (Math.abs(dy) !== this.colPieces[x1]) {
+                return { legal: false, error: "The move distance does not match the number of pieces in that column" };
+            }
+        } else if (dy === 0) {
+            if (Math.abs(dx) !== this.rowPieces[y1]) {
+                return { legal: false, error: "The move distance does not match the number of pieces in that row" };
+            }
+        } else {
+            if (Math.abs(dx) !== Math.abs(dy)) {
+                return { legal: false, error: "That move is not in a row, column, or diagonal" };
+            }
+            if ((dx > 0) === (dy > 0)) {
+                if (Math.abs(dx) !== this.diagGravePieces[this._graveIndex(x1, y1)]) {
+                    return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+                }
+            } else {
+                if (Math.abs(dx) !== this.diagAcutePieces[this._acuteIndex(x1, y1)]) {
+                    return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+                }
+            }
+        }
+
+        const stepX = dx ? (dx < 0 ? -1 : 1) : 0;
+        const stepY = dy ? (dy < 0 ? -1 : 1) : 0;
+        let x = x1 + stepX, y = y1 + stepY;
+        while (x !== x2 || y !== y2) {
+            if (this.cells[x][y] !== CELL_EMPTY &&
+                this.cells[x][y] !== this.cells[x1][y1]) {
+                return { legal: false, error: "You cannot jump an opponent's piece" };
+            }
+            x += stepX;
+            y += stepY;
+        }
+
+        return { legal: true };
+    }
+
+    move(x1, y1, x2, y2) {
+        const result = this.moveLegal(x1, y1, x2, y2);
+        if (!result.legal) return result;
+
+        const captured = this.cells[x2][y2] !== CELL_EMPTY;
+        const cell = this._removePiece(x1, y1);
+        this._removePiece(x2, y2);
+        this._addPiece(x2, y2, cell);
+        this.player = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+
+        return { legal: true, captured };
+    }
+
+    toJSON() {
+        return {
+            cells: this.cells.map(col => [...col]),
+            numPieces: [...this.numPieces],
+            player: this.player
+        };
+    }
+
+    static fromJSON(data) {
+        const board = new LoaBoard();
+        /* Clear the default init state */
+        for (let x = 0; x < BOARD_SIZE; x++)
+            for (let y = 0; y < BOARD_SIZE; y++)
+                board._removePiece(x, y);
+        /* Rebuild from serialized cells */
+        for (let x = 0; x < BOARD_SIZE; x++)
+            for (let y = 0; y < BOARD_SIZE; y++)
+                if (data.cells[x][y] !== CELL_EMPTY)
+                    board._addPiece(x, y, data.cells[x][y]);
+        board.player = data.player;
+        return board;
+    }
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+    module.exports = {
+        LoaBoard, BOARD_SIZE, DIAG_ARRAY_SIZE,
+        CELL_EMPTY, CELL_BLACK, CELL_WHITE,
+        PLAYER_BLACK, PLAYER_WHITE
+    };
+}
diff --git a/loa/loa-client.js b/loa/loa-client.js
new file mode 100644 (file)
index 0000000..c789433
--- /dev/null
@@ -0,0 +1,212 @@
+/* Lines of Action - client controller and SSE communication */
+
+(function () {
+
+let events = null;
+let board = null;
+let view = null;
+
+let gameInfo = {};
+let playerInfo = {};
+let otherPlayers = [];
+let gameOver = false;
+let animating = false;
+let winner = null;
+
+function init() {
+    board = new LoaBoard();
+    view = new LoaView(document.getElementById("loa"), {
+        onMove: handleMove
+    });
+    view.renderPieces(board);
+
+    events = new ReconnectingEventSource("events", {
+        onclose: () => add_message("danger", "Connection to server lost.")
+    });
+    setupEventHandlers();
+
+    updateStatus();
+}
+
+/* Map team name to cell value. */
+function teamToCell(teamName) {
+    if (teamName === "Black") return CELL_BLACK;
+    if (teamName === "White") return CELL_WHITE;
+    return null;
+}
+
+function myCell() {
+    return teamToCell(playerInfo.team);
+}
+
+function isMyTurn(state) {
+    if (!playerInfo.team) return false;
+    return state.team_to_play.name === playerInfo.team;
+}
+
+function isFirstMove() {
+    return !playerInfo.team && otherPlayers.every(p => !p.team);
+}
+
+function updateInteractive() {
+    const firstMove = isFirstMove();
+    const canMove = !gameOver && !animating &&
+          (firstMove || (playerInfo.team && board.player === myCell()));
+    view.setInteractive(canMove, firstMove ? board.player : myCell());
+}
+
+function updateStatus() {
+    const el = document.getElementById("loa");
+
+    /* Find or create the status bar. */
+    let status = document.getElementById("loa-status");
+    if (!status) {
+        status = document.createElement("div");
+        status.id = "loa-status";
+        el.parentNode.insertBefore(status, el);
+    }
+
+    let html = '';
+
+    if (gameOver && winner) {
+        const color = winner === "Black" ? "#222" : "#eee";
+        html = `<span class="turn-dot" style="background:${color}"></span> `;
+        html += `${winner} wins!`;
+    } else if (!playerInfo.team) {
+        /* Not yet on a team — first move assigns teams */
+        if (otherPlayers.length === 0) {
+            html = "You can move or wait for another player to join.";
+        } else {
+            html = "Either player can make the first move.";
+        }
+    } else {
+        const turnTeam = board.player === PLAYER_BLACK ? "Black" : "White";
+        const color = board.player === PLAYER_BLACK ? "#222" : "#eee";
+        html = `<span class="turn-dot" style="background:${color}"></span> `;
+        if (board.player === myCell()) {
+            html += `Your turn (${turnTeam})`;
+        } else {
+            html += `Waiting for ${turnTeam} to move...`;
+        }
+    }
+
+    status.innerHTML = html;
+    updateInteractive();
+}
+
+async function handleMove(x1, y1, x2, y2) {
+    if (gameOver || animating) return;
+
+    const body = { move: [x1, y1, x2, y2] };
+    if (isFirstMove()) {
+        body.assert_first_move = true;
+    }
+
+    try {
+        const response = await fetch("move", {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify(body)
+        });
+        const result = await response.json();
+        if (!result.legal) {
+            add_message("warning", result.message);
+        }
+    } catch (err) {
+        add_message("danger", "Failed to send move.");
+    }
+}
+
+/* ── SSE event handlers ── */
+
+function setupEventHandlers() {
+
+events.addEventListener("game-info", (e) => {
+    gameInfo = JSON.parse(e.data);
+
+    const el = document.getElementById("game-info");
+    el.innerHTML =
+        `<span class="game-id">Game: <strong>${gameInfo.id}</strong></span>` +
+        ` &mdash; <span class="game-share">Share link: ` +
+        `<a href="${gameInfo.url}">${gameInfo.url}</a></span>`;
+});
+
+events.addEventListener("player-info", (e) => {
+    playerInfo = JSON.parse(e.data);
+    updateStatus();
+});
+
+events.addEventListener("player-enter", (e) => {
+    const p = JSON.parse(e.data);
+    const idx = otherPlayers.findIndex(op => op.id === p.id);
+    if (idx >= 0)
+        otherPlayers[idx] = p;
+    else
+        otherPlayers.push(p);
+    updateStatus();
+});
+
+events.addEventListener("player-update", (e) => {
+    const p = JSON.parse(e.data);
+    if (p.id === playerInfo.id) {
+        playerInfo = p;
+    } else {
+        const idx = otherPlayers.findIndex(op => op.id === p.id);
+        if (idx >= 0)
+            otherPlayers[idx] = p;
+    }
+    updateStatus();
+});
+
+events.addEventListener("player-exit", (e) => {
+    const p = JSON.parse(e.data);
+    otherPlayers = otherPlayers.filter(op => op.id !== p.id);
+});
+
+events.addEventListener("game-state", (e) => {
+    const state = JSON.parse(e.data);
+
+    board = LoaBoard.fromJSON(state.board);
+    gameOver = !!state.winner;
+    winner = state.winner;
+
+    view.renderPieces(board);
+
+    if (gameOver && winner) {
+        const winnerCell = teamToCell(winner);
+        view.showWin(winnerCell, board);
+    }
+
+    updateStatus();
+});
+
+events.addEventListener("move", (e) => {
+    const move = JSON.parse(e.data);
+    const [x1, y1, x2, y2] = move;
+
+    const result = board.move(x1, y1, x2, y2);
+    if (!result.legal) return; /* shouldn't happen */
+
+    animating = true;
+    updateInteractive();
+
+    view.animateMove(x1, y1, x2, y2, result.captured, () => {
+        animating = false;
+
+        const w = board.checkWinner(x2, y2);
+        if (w !== null) {
+            gameOver = true;
+            winner = w === PLAYER_BLACK ? "Black" : "White";
+            view.showWin(w, board);
+        }
+
+        updateStatus();
+    });
+});
+
+} /* setupEventHandlers */
+
+/* ── Start ── */
+init();
+
+})();
diff --git a/loa/loa-view.js b/loa/loa-view.js
new file mode 100644 (file)
index 0000000..763a531
--- /dev/null
@@ -0,0 +1,419 @@
+/* Lines of Action - SVG rendering and interaction handling
+ *
+ * Supports click-to-select/click-to-move, mouse drag, and touch drag
+ * via unified pointer events.
+ */
+
+class LoaView {
+    constructor(container, callbacks) {
+        this.callbacks = callbacks; /* { onMove(x1,y1,x2,y2) } */
+        this.pieceElements = new Map(); /* "x,y" -> <g> element */
+        this.interactive = false;
+        this.currentPlayer = null; /* which color can be moved */
+
+        /* Selection state */
+        this.hasSelected = false;
+        this.selectedX = -1;
+        this.selectedY = -1;
+
+        /* Drag state: 'idle' | 'pending' | 'dragging' */
+        this.dragState = 'idle';
+        this.dragSourceX = -1;
+        this.dragSourceY = -1;
+        this.dragStartClientX = 0;
+        this.dragStartClientY = 0;
+        this.ghostPiece = null;
+
+        const NS = "http://www.w3.org/2000/svg";
+        this.svg = document.createElementNS(NS, "svg");
+        this.svg.setAttribute("viewBox", "-30 -30 860 860");
+        container.appendChild(this.svg);
+
+        /* Defs: gradients, filters */
+        const defs = this._el("defs");
+
+        const gBlack = this._el("radialGradient", { id: "grad-black", cx: "40%", cy: "35%", r: "55%" });
+        gBlack.appendChild(this._el("stop", { offset: "0%", "stop-color": "#555" }));
+        gBlack.appendChild(this._el("stop", { offset: "100%", "stop-color": "#111" }));
+        defs.appendChild(gBlack);
+
+        const gWhite = this._el("radialGradient", { id: "grad-white", cx: "40%", cy: "35%", r: "55%" });
+        gWhite.appendChild(this._el("stop", { offset: "0%", "stop-color": "#fff" }));
+        gWhite.appendChild(this._el("stop", { offset: "100%", "stop-color": "#ccc" }));
+        defs.appendChild(gWhite);
+
+        const shadow = this._el("filter", { id: "piece-shadow", x: "-20%", y: "-20%", width: "140%", height: "140%" });
+        shadow.appendChild(this._el("feDropShadow", { dx: "1.5", dy: "2", stdDeviation: "2.5", "flood-color": "#000", "flood-opacity": "0.35" }));
+        defs.appendChild(shadow);
+
+        const glow = this._el("filter", { id: "sel-glow", x: "-50%", y: "-50%", width: "200%", height: "200%" });
+        const blur = this._el("feGaussianBlur", { in: "SourceGraphic", stdDeviation: "4", result: "blur" });
+        glow.appendChild(blur);
+        const merge = this._el("feMerge");
+        merge.appendChild(this._el("feMergeNode", { in: "blur" }));
+        merge.appendChild(this._el("feMergeNode", { in: "SourceGraphic" }));
+        glow.appendChild(merge);
+        defs.appendChild(glow);
+
+        /* Lift shadow for dragged pieces */
+        const liftShadow = this._el("filter", { id: "drag-shadow", x: "-30%", y: "-30%", width: "160%", height: "160%" });
+        liftShadow.appendChild(this._el("feDropShadow", { dx: "3", dy: "5", stdDeviation: "5", "flood-color": "#000", "flood-opacity": "0.45" }));
+        defs.appendChild(liftShadow);
+
+        this.svg.appendChild(defs);
+
+        /* Layer groups */
+        this.squaresLayer = this._el("g");
+        this.piecesLayer = this._el("g");
+        this.selectionLayer = this._el("g");
+        this.dragLayer = this._el("g");
+        this.svg.appendChild(this.squaresLayer);
+        this.svg.appendChild(this.piecesLayer);
+        this.svg.appendChild(this.selectionLayer);
+        this.svg.appendChild(this.dragLayer);
+
+        this._drawBoard();
+        this._setupPointerEvents();
+    }
+
+    _el(tag, attrs) {
+        const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
+        if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
+        return el;
+    }
+
+    _drawBoard() {
+        const cols = "ABCDEFGH";
+        for (let y = 0; y < BOARD_SIZE; y++) {
+            for (let x = 0; x < BOARD_SIZE; x++) {
+                const rect = this._el("rect", {
+                    x: x * 100, y: y * 100,
+                    width: 100, height: 100,
+                    fill: (x + y) % 2 === 0 ? "#e3b366" : "#420503"
+                });
+                this.squaresLayer.appendChild(rect);
+            }
+        }
+        for (let i = 0; i < BOARD_SIZE; i++) {
+            const colLabel = this._el("text", {
+                x: i * 100 + 50, y: 820,
+                "text-anchor": "middle", fill: "#d4b896",
+                "font-size": "18", "font-family": "sans-serif"
+            });
+            colLabel.textContent = cols[i];
+            this.squaresLayer.appendChild(colLabel);
+
+            const rowLabel = this._el("text", {
+                x: -12, y: i * 100 + 56,
+                "text-anchor": "middle", fill: "#d4b896",
+                "font-size": "18", "font-family": "sans-serif"
+            });
+            rowLabel.textContent = String(BOARD_SIZE - i);
+            this.squaresLayer.appendChild(rowLabel);
+        }
+    }
+
+    _svgCoords(e) {
+        const pt = this.svg.createSVGPoint();
+        pt.x = e.clientX;
+        pt.y = e.clientY;
+        return pt.matrixTransform(this.svg.getScreenCTM().inverse());
+    }
+
+    _boardCoords(svgPt) {
+        const x = Math.floor(svgPt.x / 100);
+        const y = Math.floor(svgPt.y / 100);
+        if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE)
+            return null;
+        return { x, y };
+    }
+
+    _isOwnPiece(x, y) {
+        if (this.currentPlayer === null) return false;
+        /* currentPlayer is the board cell value (CELL_BLACK or CELL_WHITE)
+         * that the local player controls. */
+        const key = `${x},${y}`;
+        /* We check the actual board state via the piece elements and
+         * the data attribute we set on them. */
+        const el = this.pieceElements.get(key);
+        return el && el.dataset.cell === String(this.currentPlayer);
+    }
+
+    _setupPointerEvents() {
+        this.svg.style.touchAction = "none";
+
+        this.svg.addEventListener("pointerdown", (e) => {
+            if (!this.interactive) return;
+            e.preventDefault();
+
+            const svgPt = this._svgCoords(e);
+            const bc = this._boardCoords(svgPt);
+            if (!bc) return;
+            const { x, y } = bc;
+
+            if (this.hasSelected) {
+                /* Clicking the selected piece deselects. */
+                if (x === this.selectedX && y === this.selectedY) {
+                    this._deselect();
+                    return;
+                }
+                /* Clicking another own piece switches selection. */
+                if (this._isOwnPiece(x, y)) {
+                    this._select(x, y);
+                    /* Start pending drag on the newly selected piece. */
+                    this.dragState = 'pending';
+                    this.dragSourceX = x;
+                    this.dragSourceY = y;
+                    this.dragStartClientX = e.clientX;
+                    this.dragStartClientY = e.clientY;
+                    this.svg.setPointerCapture(e.pointerId);
+                    return;
+                }
+                /* Clicking empty/opponent square: attempt move. */
+                this._attemptMove(this.selectedX, this.selectedY, x, y);
+                return;
+            }
+
+            /* Nothing selected yet. Start interaction on own piece. */
+            if (this._isOwnPiece(x, y)) {
+                this.dragState = 'pending';
+                this.dragSourceX = x;
+                this.dragSourceY = y;
+                this.dragStartClientX = e.clientX;
+                this.dragStartClientY = e.clientY;
+                this.svg.setPointerCapture(e.pointerId);
+            }
+        });
+
+        this.svg.addEventListener("pointermove", (e) => {
+            if (this.dragState === 'idle') return;
+            e.preventDefault();
+
+            if (this.dragState === 'pending') {
+                const dx = e.clientX - this.dragStartClientX;
+                const dy = e.clientY - this.dragStartClientY;
+                if (dx * dx + dy * dy < 25) return; /* 5px threshold */
+                this._startDrag(this.dragSourceX, this.dragSourceY);
+            }
+
+            if (this.dragState === 'dragging') {
+                const svgPt = this._svgCoords(e);
+                this.ghostPiece.style.transform =
+                    `translate(${svgPt.x}px, ${svgPt.y}px)`;
+            }
+        });
+
+        this.svg.addEventListener("pointerup", (e) => {
+            if (this.dragState === 'idle') return;
+            e.preventDefault();
+
+            if (this.dragState === 'dragging') {
+                this._endDrag(e);
+            } else if (this.dragState === 'pending') {
+                /* No drag happened — treat as a click to select. */
+                this._select(this.dragSourceX, this.dragSourceY);
+            }
+
+            this.dragState = 'idle';
+            try { this.svg.releasePointerCapture(e.pointerId); }
+            catch (_) { /* ignore if not captured */ }
+        });
+
+        this.svg.addEventListener("pointercancel", (e) => {
+            if (this.dragState === 'dragging') {
+                this._cleanupDrag();
+            }
+            this.dragState = 'idle';
+        });
+    }
+
+    _select(x, y) {
+        this.hasSelected = true;
+        this.selectedX = x;
+        this.selectedY = y;
+        this.showSelection(x, y);
+    }
+
+    _deselect() {
+        this.hasSelected = false;
+        this.clearSelection();
+    }
+
+    _attemptMove(x1, y1, x2, y2) {
+        this._deselect();
+        this.callbacks.onMove(x1, y1, x2, y2);
+    }
+
+    _startDrag(x, y) {
+        this.dragState = 'dragging';
+        this._select(x, y);
+
+        /* Dim the original piece. */
+        const key = `${x},${y}`;
+        const piece = this.pieceElements.get(key);
+        if (piece) piece.style.opacity = "0.35";
+
+        /* Create ghost piece in the drag layer. */
+        const cell = parseInt(piece.dataset.cell);
+        const isBlack = cell === CELL_BLACK;
+        const g = this._el("g", { class: "piece ghost" });
+        g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+        const circle = this._el("circle", {
+            r: 40,
+            fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+            stroke: isBlack ? "#6b4a2a" : "#8b7355",
+            "stroke-width": "1.5",
+            filter: "url(#drag-shadow)"
+        });
+        g.appendChild(circle);
+        this.dragLayer.appendChild(g);
+        this.ghostPiece = g;
+    }
+
+    _endDrag(e) {
+        const svgPt = this._svgCoords(e);
+        const bc = this._boardCoords(svgPt);
+
+        this._cleanupDrag();
+
+        if (bc && (bc.x !== this.dragSourceX || bc.y !== this.dragSourceY)) {
+            this._attemptMove(this.dragSourceX, this.dragSourceY, bc.x, bc.y);
+        }
+    }
+
+    _cleanupDrag() {
+        /* Remove ghost. */
+        if (this.ghostPiece) {
+            this.ghostPiece.remove();
+            this.ghostPiece = null;
+        }
+        /* Restore original piece opacity. */
+        const key = `${this.dragSourceX},${this.dragSourceY}`;
+        const piece = this.pieceElements.get(key);
+        if (piece) piece.style.opacity = "";
+    }
+
+    /* Set which player color the local user controls and whether
+     * interaction is enabled. */
+    setInteractive(canMove, playerCell) {
+        this.interactive = canMove;
+        this.currentPlayer = playerCell;
+    }
+
+    renderPieces(board) {
+        const newKeys = new Set();
+
+        for (let x = 0; x < BOARD_SIZE; x++) {
+            for (let y = 0; y < BOARD_SIZE; y++) {
+                const cell = board.cells[x][y];
+                if (cell === CELL_EMPTY) continue;
+                const key = `${x},${y}`;
+                newKeys.add(key);
+                if (!this.pieceElements.has(key)) {
+                    const g = this._createPiece(x, y, cell);
+                    this.piecesLayer.appendChild(g);
+                    this.pieceElements.set(key, g);
+                }
+            }
+        }
+
+        for (const [key, el] of this.pieceElements) {
+            if (!newKeys.has(key)) {
+                el.remove();
+                this.pieceElements.delete(key);
+            }
+        }
+    }
+
+    _createPiece(x, y, cell) {
+        const g = this._el("g", { class: "piece" });
+        g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+        g.dataset.cell = String(cell);
+
+        const isBlack = cell === CELL_BLACK;
+        const circle = this._el("circle", {
+            r: 40,
+            fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+            stroke: isBlack ? "#6b4a2a" : "#8b7355",
+            "stroke-width": "1.5",
+            filter: "url(#piece-shadow)"
+        });
+        g.appendChild(circle);
+        return g;
+    }
+
+    animateMove(x1, y1, x2, y2, captured, callback) {
+        const fromKey = `${x1},${y1}`;
+        const toKey = `${x2},${y2}`;
+        const piece = this.pieceElements.get(fromKey);
+        if (!piece) { if (callback) callback(); return; }
+
+        if (captured) {
+            const target = this.pieceElements.get(toKey);
+            if (target) {
+                target.classList.add("captured");
+                setTimeout(() => target.remove(), 300);
+                this.pieceElements.delete(toKey);
+            }
+        }
+
+        this.pieceElements.delete(fromKey);
+        this.pieceElements.set(toKey, piece);
+
+        piece.style.transform = `translate(${x2 * 100 + 50}px, ${y2 * 100 + 50}px)`;
+
+        setTimeout(() => { if (callback) callback(); }, 310);
+    }
+
+    showSelection(x, y) {
+        this.clearSelection();
+
+        const g = this._el("g", { id: "selection-ring" });
+        g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+        const ring = this._el("circle", {
+            r: 42, fill: "none",
+            stroke: "#4488ff", "stroke-width": "3",
+            filter: "url(#sel-glow)", opacity: "0.8"
+        });
+        g.appendChild(ring);
+        this.selectionLayer.appendChild(g);
+
+        const key = `${x},${y}`;
+        const piece = this.pieceElements.get(key);
+        if (piece) piece.classList.add("selected");
+    }
+
+    clearSelection() {
+        this.selectionLayer.innerHTML = "";
+        for (const el of this.pieceElements.values()) {
+            el.classList.remove("selected");
+        }
+    }
+
+    showWin(winner, board) {
+        const rings = [];
+        for (let x = 0; x < BOARD_SIZE; x++) {
+            for (let y = 0; y < BOARD_SIZE; y++) {
+                if (board.cells[x][y] === winner) {
+                    const g = this._el("g", { class: "win-ring" });
+                    g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+                    const ring = this._el("circle", {
+                        r: 46, fill: "none",
+                        stroke: "#4488ff", "stroke-width": "4",
+                        filter: "url(#sel-glow)"
+                    });
+                    g.appendChild(ring);
+                    this.selectionLayer.appendChild(g);
+                    rings.push(g);
+                }
+            }
+        }
+        requestAnimationFrame(() => {
+            requestAnimationFrame(() => {
+                rings.forEach(g => g.classList.add("visible"));
+            });
+        });
+    }
+}
diff --git a/loa/loa.css b/loa/loa.css
new file mode 100644 (file)
index 0000000..1d68426
--- /dev/null
@@ -0,0 +1,72 @@
+#loa {
+    width: min(90vw, 80vh);
+    aspect-ratio: 1;
+    margin: 0 auto;
+}
+
+#loa svg {
+    width: 100%;
+    height: 100%;
+    display: block;
+    touch-action: none;
+}
+
+#loa-status {
+    text-align: center;
+    margin-bottom: 8px;
+    font-size: 16px;
+    min-height: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+}
+
+.turn-dot {
+    display: inline-block;
+    width: 14px;
+    height: 14px;
+    border-radius: 50%;
+    border: 1px solid #888;
+    vertical-align: middle;
+}
+
+.piece {
+    transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
+    cursor: pointer;
+}
+
+.piece.captured {
+    opacity: 0;
+    pointer-events: none;
+}
+
+.piece.selected circle {
+    transform: scale(1.08);
+    transform-origin: 0px 0px;
+}
+
+.piece.ghost {
+    pointer-events: none;
+    opacity: 0.75;
+    transition: none;
+}
+
+.win-ring {
+    opacity: 0;
+    transition: opacity 0.6s ease-out;
+}
+
+.win-ring.visible {
+    opacity: 0.8;
+}
+
+#game-info {
+    text-align: center;
+    margin-bottom: 12px;
+    font-size: 14px;
+}
+
+#game-info a {
+    color: inherit;
+}