]> git.cworth.org Git - lmno-server/commitdiff
Add Anagrams game server engine
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 00:06:48 +0000 (19:06 -0500)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 00:06:48 +0000 (19:06 -0500)
New game with real-time word claiming from a shared letter pool.
Features queued claiming, word stealing, dictionary validation
with player voting for non-TWL words, scaled letter-request
timer, and game-end steal mechanic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
anagrams.js [new file with mode: 0644]
lmno.js
templates/anagrams-game.html [new file with mode: 0644]

diff --git a/anagrams.js b/anagrams.js
new file mode 100644 (file)
index 0000000..c081214
--- /dev/null
@@ -0,0 +1,887 @@
+const express = require("express");
+const Game = require("./game.js");
+const TWL_WORDS = new Set(require("./twl-words.js"));
+const { TILE_DISTRIBUTION_NO_BLANKS, make_bag } = require("./tiles.js");
+
+const CLAIM_TIMEOUT_MS = 60000;    /* 60 seconds to form a word */
+const CLAIM_WARNING_MS = 15000;    /* show timer for last 15 seconds */
+const VOTE_TIMEOUT_MS = 15000;     /* 15 seconds to vote */
+const REVEAL_COUNTDOWN_MS = 3000;  /* 3 seconds to reveal a tile */
+const MIN_WORD_LENGTH = 4;
+
+class Anagrams extends Game {
+  constructor(id) {
+    super(id);
+    this.state = {
+      bag: make_bag(TILE_DISTRIBUTION_NO_BLANKS),
+      /* Center letters: array of { id, letter }.
+       * id is a unique incrementing integer for tracking. */
+      center: [],
+      next_letter_id: 0,
+      /* Per-player claimed words: { session_id: [ { id, word, letters } ] }
+       * letters is an array of { id, letter } for display. */
+      player_words: {},
+      /* Claim queue: array of session_ids. First entry is active claimer. */
+      claim_queue: [],
+      /* Letters/words currently held by the active claimer. */
+      claimed_letters: [],   /* letter objects from center */
+      claimed_words: [],     /* { owner_session, word_id, word_obj } */
+      /* Voting state. */
+      vote_pending: null,
+      /* Letter request state. */
+      letter_requests: new Set(),
+      /* Game state. */
+      finished: false,
+      done_players: new Set()
+    };
+    this._claim_timer = null;
+    this._claim_warning_timer = null;
+    this._vote_timer = null;
+    this._letter_request_timer = null;
+    this._reveal_timer = null;
+  }
+
+  /*****************************************************
+   * Helpers                                           *
+   *****************************************************/
+
+  active_claimer_session() {
+    return this.state.claim_queue.length > 0
+      ? this.state.claim_queue[0] : null;
+  }
+
+  active_claimer_player() {
+    const sid = this.active_claimer_session();
+    return sid ? this.players_by_session[sid] : null;
+  }
+
+  /* Count active players who have joined (have a words array). */
+  joined_player_count() {
+    let count = 0;
+    for (const sid of Object.keys(this.state.player_words)) {
+      const p = this.players_by_session[sid];
+      if (p && p.active) count++;
+    }
+    return count;
+  }
+
+  /*****************************************************
+   * Letter management                                 *
+   *****************************************************/
+
+  /* Deal a letter from bag to center with a countdown reveal. */
+  deal_letter() {
+    if (this.state.bag.length === 0) return false;
+
+    const letter = this.state.bag.pop();
+    const id = this.state.next_letter_id++;
+    const tile = { id, letter };
+
+    this.state.center.push(tile);
+    this.state.letter_requests = new Set();
+
+    this.broadcast_event_object("letter-reveal", {
+      tile,
+      remaining: this.state.bag.length,
+      countdown_ms: REVEAL_COUNTDOWN_MS
+    });
+
+    /* Also broadcast updated bag count. */
+    this.broadcast_event_object("bag-count", {
+      remaining: this.state.bag.length
+    });
+
+    return true;
+  }
+
+  /* Handle a player requesting a new letter. */
+  handle_request_letter(request, response) {
+    const session_id = request.session.id;
+    if (!this.state.player_words[session_id]) {
+      response.sendStatus(400);
+      return;
+    }
+
+    if (this.state.bag.length === 0) {
+      response.json({ remaining: 0 });
+      return;
+    }
+
+    /* Don't allow requests during a reveal countdown. */
+    if (this._reveal_timer) {
+      response.json({ queued: false });
+      return;
+    }
+
+    this.state.letter_requests.add(session_id);
+
+    const joined = this.joined_player_count();
+    const votes = this.state.letter_requests.size;
+
+    this.broadcast_event_object("letter-request", {
+      votes,
+      needed: joined
+    });
+
+    /* Scale timer: all players voting = immediate.
+     * One player = full 10-second wait.
+     * Linear interpolation between. */
+    if (this._letter_request_timer) {
+      clearTimeout(this._letter_request_timer);
+      this._letter_request_timer = null;
+    }
+
+    if (votes >= joined) {
+      this.deal_letter();
+    } else {
+      const ratio = votes / joined;
+      const delay_ms = Math.round(10000 * (1 - ratio));
+      this._letter_request_timer = setTimeout(() => {
+        this._letter_request_timer = null;
+        this.deal_letter();
+      }, delay_ms);
+    }
+
+    response.json({ queued: true, remaining: this.state.bag.length });
+  }
+
+  /*****************************************************
+   * Claim mode                                        *
+   *****************************************************/
+
+  handle_claim(request, response) {
+    const session_id = request.session.id;
+    if (!this.state.player_words[session_id]) {
+      response.sendStatus(400);
+      return;
+    }
+
+    /* Don't allow claiming if game is finished. */
+    if (this.state.finished) {
+      response.json({ queued: false });
+      return;
+    }
+
+    /* Don't allow if already in queue. */
+    if (this.state.claim_queue.includes(session_id)) {
+      response.json({ queued: true, active: session_id === this.active_claimer_session() });
+      return;
+    }
+
+    this.state.claim_queue.push(session_id);
+
+    /* If this is the only person in queue, activate them. */
+    if (this.state.claim_queue.length === 1) {
+      this._activate_claimer();
+    } else {
+      /* Let them know they're queued. */
+      const player = this.players_by_session[session_id];
+      if (player) {
+        player.send(`event: claim-queued\ndata: ${JSON.stringify({
+          position: this.state.claim_queue.indexOf(session_id)
+        })}\n\n`);
+      }
+    }
+
+    response.json({ queued: true, active: session_id === this.active_claimer_session() });
+  }
+
+  _activate_claimer() {
+    const session_id = this.active_claimer_session();
+    if (!session_id) return;
+
+    this.state.claimed_letters = [];
+    this.state.claimed_words = [];
+
+    const player = this.players_by_session[session_id];
+    this.broadcast_event_object("claim-start", {
+      player_name: player ? player.name : "Unknown",
+      timeout_ms: CLAIM_TIMEOUT_MS,
+      warning_ms: CLAIM_WARNING_MS
+    });
+
+    /* Start claim timeout. */
+    this._claim_warning_timer = setTimeout(() => {
+      this.broadcast_event_object("claim-warning", {
+        remaining_ms: CLAIM_WARNING_MS
+      });
+    }, CLAIM_TIMEOUT_MS - CLAIM_WARNING_MS);
+
+    this._claim_timer = setTimeout(() => {
+      this._cancel_claim("timeout");
+    }, CLAIM_TIMEOUT_MS);
+  }
+
+  /* Take a letter from the center. */
+  handle_take_letter(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    const letter_id = request.body.letter_id;
+    const idx = this.state.center.findIndex(t => t.id === letter_id);
+    if (idx < 0) {
+      response.sendStatus(404);
+      return;
+    }
+
+    const tile = this.state.center.splice(idx, 1)[0];
+    this.state.claimed_letters.push(tile);
+
+    const player = this.players_by_session[session_id];
+    this.broadcast_event_object("letter-claimed", {
+      player_name: player ? player.name : "Unknown",
+      tile
+    });
+
+    response.json({ ok: true });
+  }
+
+  /* Return a letter to the center. */
+  handle_return_letter(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    const letter_id = request.body.letter_id;
+    const idx = this.state.claimed_letters.findIndex(t => t.id === letter_id);
+    if (idx < 0) {
+      response.sendStatus(404);
+      return;
+    }
+
+    const tile = this.state.claimed_letters.splice(idx, 1)[0];
+    this.state.center.push(tile);
+
+    const player = this.players_by_session[session_id];
+    this.broadcast_event_object("letter-returned", {
+      player_name: player ? player.name : "Unknown",
+      tile
+    });
+
+    response.json({ ok: true });
+  }
+
+  /* Steal a word from a player. */
+  handle_steal_word(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    const { owner_session, word_id } = request.body;
+    const owner_words = this.state.player_words[owner_session];
+    if (!owner_words) {
+      response.sendStatus(404);
+      return;
+    }
+
+    const word_idx = owner_words.findIndex(w => w.id === word_id);
+    if (word_idx < 0) {
+      response.sendStatus(404);
+      return;
+    }
+
+    /* Check if already claimed this word. */
+    if (this.state.claimed_words.find(cw => cw.word_id === word_id)) {
+      response.sendStatus(409);
+      return;
+    }
+
+    const word_obj = owner_words[word_idx];
+
+    /* Remove from owner and add to claimed. */
+    owner_words.splice(word_idx, 1);
+    this.state.claimed_words.push({ owner_session, word_id, word_obj });
+
+    const player = this.players_by_session[session_id];
+    const owner_player = this.players_by_session[owner_session];
+    this.broadcast_event_object("word-stolen", {
+      player_name: player ? player.name : "Unknown",
+      from_player: owner_player ? owner_player.name : "Unknown",
+      from_session: owner_session,
+      word: word_obj.word,
+      word_id
+    });
+
+    response.json({ ok: true });
+  }
+
+  /* Return a stolen word to its owner. */
+  handle_return_word(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    const word_id = request.body.word_id;
+    const idx = this.state.claimed_words.findIndex(cw => cw.word_id === word_id);
+    if (idx < 0) {
+      response.sendStatus(404);
+      return;
+    }
+
+    const { owner_session, word_obj } = this.state.claimed_words.splice(idx, 1)[0];
+    this.state.player_words[owner_session].push(word_obj);
+
+    const player = this.players_by_session[session_id];
+    const owner_player = this.players_by_session[owner_session];
+    this.broadcast_event_object("word-returned", {
+      player_name: player ? player.name : "Unknown",
+      to_player: owner_player ? owner_player.name : "Unknown",
+      to_session: owner_session,
+      word: word_obj.word,
+      word_id
+    });
+
+    response.json({ ok: true });
+  }
+
+  /* Cancel claim and return all letters/words. */
+  handle_cancel_claim(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    this._cancel_claim("cancelled");
+    response.json({ ok: true });
+  }
+
+  _cancel_claim(reason) {
+    /* Return all claimed letters to center. */
+    for (const tile of this.state.claimed_letters) {
+      this.state.center.push(tile);
+    }
+    this.state.claimed_letters = [];
+
+    /* Return all claimed words to their owners. */
+    for (const { owner_session, word_obj } of this.state.claimed_words) {
+      this.state.player_words[owner_session].push(word_obj);
+    }
+    this.state.claimed_words = [];
+
+    this._clear_claim_timers();
+
+    const session_id = this.state.claim_queue.shift();
+    const player = this.players_by_session[session_id];
+    this.broadcast_event_object("claim-end", {
+      player_name: player ? player.name : "Unknown",
+      reason
+    });
+
+    /* Activate next claimer if any. */
+    if (this.state.claim_queue.length > 0) {
+      this._activate_claimer();
+    }
+  }
+
+  _clear_claim_timers() {
+    if (this._claim_timer) {
+      clearTimeout(this._claim_timer);
+      this._claim_timer = null;
+    }
+    if (this._claim_warning_timer) {
+      clearTimeout(this._claim_warning_timer);
+      this._claim_warning_timer = null;
+    }
+  }
+
+  /*****************************************************
+   * Word submission and validation                    *
+   *****************************************************/
+
+  handle_submit(request, response) {
+    const session_id = request.session.id;
+    if (session_id !== this.active_claimer_session()) {
+      response.sendStatus(403);
+      return;
+    }
+
+    const word = (request.body.word || "").toUpperCase();
+    if (word.length < MIN_WORD_LENGTH) {
+      response.json({ ok: false, error: "Word must be at least 4 letters" });
+      return;
+    }
+
+    /* Verify the submitted word uses exactly the claimed letters. */
+    const available = [];
+    for (const tile of this.state.claimed_letters) {
+      available.push(tile.letter);
+    }
+    for (const cw of this.state.claimed_words) {
+      for (const ch of cw.word_obj.word) {
+        available.push(ch);
+      }
+    }
+
+    if (word.length !== available.length) {
+      response.json({ ok: false, error: "Word must use all claimed letters" });
+      return;
+    }
+
+    /* Check letter-by-letter match. */
+    const avail_sorted = available.slice().sort().join("");
+    const word_sorted = word.split("").sort().join("");
+    if (avail_sorted !== word_sorted) {
+      response.json({ ok: false, error: "Word must use exactly the claimed letters" });
+      return;
+    }
+
+    /* Validate steal rules. */
+    const steal_error = this._validate_steal(word);
+    if (steal_error) {
+      response.json({ ok: false, error: steal_error });
+      return;
+    }
+
+    /* Check 4-letter word limit. */
+    if (word.length === 4) {
+      const existing_four = (this.state.player_words[session_id] || [])
+        .find(w => w.word.length === 4);
+      if (existing_four) {
+        response.json({ ok: false, error: "You already have a 4-letter word" });
+        return;
+      }
+    }
+
+    /* Check dictionary. */
+    if (TWL_WORDS.has(word)) {
+      this._accept_word(session_id, word);
+      response.json({ ok: true, accepted: true });
+    } else {
+      /* Start voting. */
+      this._start_vote(session_id, word);
+      response.json({ ok: true, voting: true });
+    }
+  }
+
+  /* Validate that steal rules are followed.
+   * Returns an error string, or null if valid. */
+  _validate_steal(word) {
+    const stolen = this.state.claimed_words;
+    const center_count = this.state.claimed_letters.length;
+
+    if (stolen.length === 0) {
+      /* Pure center claim — no steal rules to check. */
+      return null;
+    }
+
+    /* Can't just rearrange a single word to the same length. */
+    if (stolen.length === 1 && center_count === 0) {
+      return "Must add at least one letter when stealing";
+    }
+
+    /* For single-word steal: the original word must not appear as a
+     * contiguous prefix or suffix (but can appear in the middle). */
+    if (stolen.length === 1) {
+      const original = stolen[0].word_obj.word;
+      if (word.startsWith(original) && !word.endsWith(original)) {
+        /* Only appended to end. */
+        return "Cannot steal by only adding letters to the end";
+      }
+      if (word.endsWith(original) && !word.startsWith(original)) {
+        /* Only prepended to front. */
+        return "Cannot steal by only adding letters to the front";
+      }
+      if (word.startsWith(original) && word.endsWith(original)
+          && word.length === original.length) {
+        /* Same word (shouldn't happen due to length check above). */
+        return "Must add at least one letter when stealing";
+      }
+      /* startsWith AND endsWith with added letters = both sides = OK.
+       * Neither starts nor ends = rearranged = OK. */
+    }
+
+    return null;
+  }
+
+  _accept_word(session_id, word) {
+    const word_id = this.state.next_letter_id++;
+
+    /* Collect the letter objects for display. */
+    const letters = [];
+    for (const tile of this.state.claimed_letters) {
+      letters.push(tile);
+    }
+    for (const cw of this.state.claimed_words) {
+      for (const ch of cw.word_obj.word) {
+        letters.push({ id: this.state.next_letter_id++, letter: ch });
+      }
+    }
+
+    const word_obj = { id: word_id, word, letters };
+    this.state.player_words[session_id].push(word_obj);
+
+    /* Clear claimed state. */
+    this.state.claimed_letters = [];
+    this.state.claimed_words = [];
+    this._clear_claim_timers();
+
+    const session_id_done = this.state.claim_queue.shift();
+    const player = this.players_by_session[session_id_done];
+
+    const score = this._compute_score(session_id);
+    this.broadcast_event_object("word-accepted", {
+      player_name: player ? player.name : "Unknown",
+      session: session_id,
+      word,
+      word_id,
+      score
+    });
+
+    /* Broadcast full player words for state sync. */
+    this._broadcast_player_state();
+
+    /* Activate next claimer if any. */
+    if (this.state.claim_queue.length > 0) {
+      this._activate_claimer();
+    }
+  }
+
+  _compute_score(session_id) {
+    const words = this.state.player_words[session_id] || [];
+    let score = 0;
+    for (const w of words) {
+      score += w.word.length - 1;
+    }
+    return score;
+  }
+
+  _compute_all_scores() {
+    const scores = {};
+    for (const sid of Object.keys(this.state.player_words)) {
+      const player = this.players_by_session[sid];
+      scores[sid] = {
+        name: player ? player.name : "Unknown",
+        score: this._compute_score(sid),
+        words: this.state.player_words[sid].map(w => w.word)
+      };
+    }
+    return scores;
+  }
+
+  /*****************************************************
+   * Voting                                            *
+   *****************************************************/
+
+  _start_vote(session_id, word) {
+    this.state.vote_pending = {
+      session_id,
+      word,
+      votes: {},  /* session_id -> true/false */
+      /* Submitter's vote is implicitly "accept". */
+    };
+
+    const player = this.players_by_session[session_id];
+    this.broadcast_event_object("vote-start", {
+      player_name: player ? player.name : "Unknown",
+      word,
+      timeout_ms: VOTE_TIMEOUT_MS
+    });
+
+    this._vote_timer = setTimeout(() => {
+      this._resolve_vote();
+    }, VOTE_TIMEOUT_MS);
+  }
+
+  handle_vote(request, response) {
+    const session_id = request.session.id;
+    const vote = this.state.vote_pending;
+
+    if (!vote) {
+      response.sendStatus(400);
+      return;
+    }
+
+    /* Submitter cannot vote (their vote is implicit). */
+    if (session_id === vote.session_id) {
+      response.sendStatus(400);
+      return;
+    }
+
+    vote.votes[session_id] = !!request.body.accept;
+
+    this.broadcast_event_object("vote-update", {
+      votes_cast: Object.keys(vote.votes).length,
+      voters_total: this.joined_player_count() - 1
+    });
+
+    /* If all other players have voted, resolve immediately. */
+    if (Object.keys(vote.votes).length >= this.joined_player_count() - 1) {
+      this._resolve_vote();
+    }
+
+    response.json({ ok: true });
+  }
+
+  _resolve_vote() {
+    if (this._vote_timer) {
+      clearTimeout(this._vote_timer);
+      this._vote_timer = null;
+    }
+
+    const vote = this.state.vote_pending;
+    if (!vote) return;
+
+    /* Count votes. Non-voters are treated as abstentions. */
+    let accept = 0, reject = 0;
+    for (const v of Object.values(vote.votes)) {
+      if (v) accept++;
+      else reject++;
+    }
+
+    /* Majority rules, ties accept (submitter is the tiebreaker). */
+    const accepted = accept >= reject;
+
+    this.state.vote_pending = null;
+
+    if (accepted) {
+      this._accept_word(vote.session_id, vote.word);
+    } else {
+      /* Word rejected — cancel the claim. */
+      this._cancel_claim("word rejected");
+    }
+
+    this.broadcast_event_object("vote-result", {
+      word: vote.word,
+      accepted,
+      accept_count: accept,
+      reject_count: reject
+    });
+  }
+
+  /*****************************************************
+   * Join / Events / Game lifecycle                    *
+   *****************************************************/
+
+  handle_join(request, response) {
+    const session_id = request.session.id;
+    const player = this.players_by_session[session_id];
+
+    if (!player) {
+      response.sendStatus(404);
+      return;
+    }
+
+    if (!this.state.player_words[session_id]) {
+      this.state.player_words[session_id] = [];
+    }
+
+    response.json({
+      center: this.state.center,
+      player_words: this._all_player_words(),
+      scores: this._compute_all_scores(),
+      remaining: this.state.bag.length
+    });
+  }
+
+  handle_done(request, response) {
+    const session_id = request.session.id;
+    if (!this.state.player_words[session_id]) {
+      response.sendStatus(400);
+      return;
+    }
+
+    this.state.done_players.add(session_id);
+
+    const joined = this.joined_player_count();
+    const done = this.state.done_players.size;
+
+    this.broadcast_event_object("done-update", { done, needed: joined });
+
+    if (done >= joined) {
+      this._finish_game();
+    }
+
+    response.json({ ok: true });
+  }
+
+  _finish_game() {
+    this.state.finished = true;
+
+    /* Attempt a final "game steal" for fun. */
+    const game_steal = this._find_game_steal();
+
+    this.broadcast_event_object("game-over", {
+      scores: this._compute_all_scores(),
+      game_steal
+    });
+  }
+
+  /* Find a possible steal the "game" could make using words from
+   * different players. Returns null or a description of the steal. */
+  _find_game_steal() {
+    const all_words = [];
+    for (const sid of Object.keys(this.state.player_words)) {
+      for (const w of this.state.player_words[sid]) {
+        const player = this.players_by_session[sid];
+        all_words.push({
+          word: w.word,
+          owner: player ? player.name : "Unknown",
+          session: sid,
+          word_id: w.id
+        });
+      }
+    }
+
+    /* Try combining pairs of words to see if they form a valid word. */
+    for (let i = 0; i < all_words.length; i++) {
+      for (let j = i + 1; j < all_words.length; j++) {
+        const combined = (all_words[i].word + all_words[j].word).split("");
+        combined.sort();
+        const sorted = combined.join("");
+
+        /* Check all TWL words of this length. */
+        for (const candidate of TWL_WORDS) {
+          if (candidate.length !== combined.length) continue;
+          const candidate_sorted = candidate.split("").sort().join("");
+          if (candidate_sorted === sorted) {
+            return {
+              words: [all_words[i], all_words[j]],
+              result: candidate
+            };
+          }
+        }
+      }
+    }
+
+    return null;
+  }
+
+  _all_player_words() {
+    const result = {};
+    for (const sid of Object.keys(this.state.player_words)) {
+      const player = this.players_by_session[sid];
+      result[sid] = {
+        name: player ? player.name : "Unknown",
+        words: this.state.player_words[sid]
+      };
+    }
+    return result;
+  }
+
+  _broadcast_player_state() {
+    this.broadcast_event_object("player-words", this._all_player_words());
+    this.broadcast_event_object("scores", this._compute_all_scores());
+  }
+
+  handle_events(request, response) {
+    super.handle_events(request, response);
+
+    const session_id = request.session.id;
+
+    /* Send center state. */
+    response.write(`event: center\ndata: ${JSON.stringify(
+      this.state.center
+    )}\n\n`);
+
+    /* Send all player words. */
+    response.write(`event: player-words\ndata: ${JSON.stringify(
+      this._all_player_words()
+    )}\n\n`);
+
+    /* Send scores. */
+    response.write(`event: scores\ndata: ${JSON.stringify(
+      this._compute_all_scores()
+    )}\n\n`);
+
+    /* Send bag count. */
+    response.write(`event: bag-count\ndata: ${JSON.stringify({
+      remaining: this.state.bag.length
+    })}\n\n`);
+
+    /* Send active claim state if any. */
+    if (this.state.claim_queue.length > 0) {
+      const claimer = this.active_claimer_player();
+      response.write(`event: claim-start\ndata: ${JSON.stringify({
+        player_name: claimer ? claimer.name : "Unknown",
+        timeout_ms: 0,
+        warning_ms: 0
+      })}\n\n`);
+    }
+
+    /* Send vote state if any. */
+    if (this.state.vote_pending) {
+      const v = this.state.vote_pending;
+      const submitter = this.players_by_session[v.session_id];
+      response.write(`event: vote-start\ndata: ${JSON.stringify({
+        player_name: submitter ? submitter.name : "Unknown",
+        word: v.word,
+        timeout_ms: 0
+      })}\n\n`);
+    }
+
+    /* Send game-over if finished. */
+    if (this.state.finished) {
+      response.write(`event: game-over\ndata: ${JSON.stringify({
+        scores: this._compute_all_scores(),
+        game_steal: null
+      })}\n\n`);
+    }
+  }
+}
+
+Anagrams.router = express.Router();
+const router = Anagrams.router;
+
+router.post('/join', (request, response) => {
+  request.game.handle_join(request, response);
+});
+
+router.post('/claim', (request, response) => {
+  request.game.handle_claim(request, response);
+});
+
+router.post('/take-letter', (request, response) => {
+  request.game.handle_take_letter(request, response);
+});
+
+router.post('/return-letter', (request, response) => {
+  request.game.handle_return_letter(request, response);
+});
+
+router.post('/steal-word', (request, response) => {
+  request.game.handle_steal_word(request, response);
+});
+
+router.post('/return-word', (request, response) => {
+  request.game.handle_return_word(request, response);
+});
+
+router.post('/submit', (request, response) => {
+  request.game.handle_submit(request, response);
+});
+
+router.post('/cancel-claim', (request, response) => {
+  request.game.handle_cancel_claim(request, response);
+});
+
+router.post('/request-letter', (request, response) => {
+  request.game.handle_request_letter(request, response);
+});
+
+router.post('/vote', (request, response) => {
+  request.game.handle_vote(request, response);
+});
+
+router.post('/done', (request, response) => {
+  request.game.handle_done(request, response);
+});
+
+Anagrams.meta = {
+  name: "Anagrams",
+  identifier: "anagrams",
+  options: {
+    allow_guest: true
+  }
+};
+
+exports.Game = Anagrams;
diff --git a/lmno.js b/lmno.js
index 5b0559bee278bef8f5957556eaec672e21b0f567..deb81724323f438642aaba8a92db40200fd02ac9 100644 (file)
--- a/lmno.js
+++ b/lmno.js
@@ -117,7 +117,8 @@ const engines = {
   tictactoe: require("./tictactoe").Game,
   scribe: require("./scribe").Game,
   empathy: require("./empathy").Game,
-  letterrip: require("./letterrip").Game
+  letterrip: require("./letterrip").Game,
+  anagrams: require("./anagrams").Game
 };
 
 class LMNO {
diff --git a/templates/anagrams-game.html b/templates/anagrams-game.html
new file mode 100644 (file)
index 0000000..8e43747
--- /dev/null
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block head %}
+<link rel="stylesheet" href="/tiles.css" type="text/css" />
+<link rel="stylesheet" href="/anagrams/anagrams.css" type="text/css" />
+
+<script src="/react.js"></script>
+<script src="/react-dom.js"></script>
+<script type="module" src="/anagrams/anagrams.js"></script>
+{% endblock %}
+
+{% block page %}
+<h1><a href="/anagrams">Anagrams</a></h1>
+
+<div id="anagrams"></div>
+
+{% endblock %}