]> git.cworth.org Git - lmno-server/commitdiff
Initial implementation of Letter Rip
authorCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 11:53:31 +0000 (03:53 -0800)
committerCarl Worth <cworth@cworth.org>
Fri, 6 Mar 2026 13:21:38 +0000 (08:21 -0500)
This is the game we originally learned as "Speed Scrabble", (but
similar to what others may have learned later as Bananagrams).

This implementation was created with the assistance of claude opus
4.6.

letterrip.js [new file with mode: 0644]
lmno.js
templates/letterrip-game.html [new file with mode: 0644]

diff --git a/letterrip.js b/letterrip.js
new file mode 100644 (file)
index 0000000..23e682d
--- /dev/null
@@ -0,0 +1,261 @@
+const express = require("express");
+const Game = require("./game.js");
+
+/* Tile distribution (98 tiles + 2 blanks = 100).
+ * Letter counts: A9 B2 C2 D4 E12 F2 G3 H2 I9 J1 K1 L4 M2
+ *                N6 O8 P2 Q1 R6 S4 T6 U4 V2 W2 X1 Y2 Z1
+ */
+const TILE_DISTRIBUTION =
+  "AAAAAAAAA" +
+  "BB" + "CC" + "DDDD" +
+  "EEEEEEEEEEEE" +
+  "FF" + "GGG" + "HH" +
+  "IIIIIIIII" +
+  "J" + "K" +
+  "LLLL" + "MM" +
+  "NNNNNN" +
+  "OOOOOOOO" +
+  "PP" + "Q" +
+  "RRRRRR" +
+  "SSSS" +
+  "TTTTTT" +
+  "UUUU" +
+  "VV" + "WW" +
+  "X" + "YY" + "Z" +
+  "__";
+
+const INITIAL_TILES = 7;
+
+class LetterRip extends Game {
+  constructor(id) {
+    super(id);
+    this.state = {
+      bag: LetterRip.make_bag(),
+      player_tiles: {},
+      stuck: new Set(),
+      finished: false,
+      winner: null
+    };
+  }
+
+  static make_bag() {
+    const tiles = TILE_DISTRIBUTION.split("");
+    /* Fisher-Yates shuffle */
+    for (let i = tiles.length - 1; i > 0; i--) {
+      const j = Math.floor(Math.random() * (i + 1));
+      [tiles[i], tiles[j]] = [tiles[j], tiles[i]];
+    }
+    return tiles;
+  }
+
+  /* Deal count_per_player tiles to each active player.
+   * Returns true on success, false if not enough tiles. */
+  deal(count_per_player) {
+    const sessions = Object.keys(this.state.player_tiles);
+    const needed = count_per_player * sessions.length;
+
+    if (this.state.bag.length < needed) {
+      if (count_per_player > 1) {
+        return this.deal(count_per_player - 1);
+      }
+      return false;
+    }
+
+    for (const session_id of sessions) {
+      const new_tiles = [];
+      for (let i = 0; i < count_per_player; i++) {
+        new_tiles.push(this.state.bag.pop());
+      }
+      this.state.player_tiles[session_id].push(...new_tiles);
+
+      /* Send new tiles only to this specific player. */
+      const player = this.players_by_session[session_id];
+      if (player) {
+        const data = JSON.stringify({ tiles: new_tiles });
+        player.send(`event: new-tiles\ndata: ${data}\n\n`);
+      }
+    }
+
+    this.state.stuck = new Set();
+    this.broadcast_event_object("dealt", {
+      remaining: this.state.bag.length
+    });
+
+    return true;
+  }
+
+  handle_join(request, response) {
+    const session_id = request.session.id;
+    const player = this.players_by_session[session_id];
+
+    if (!player) {
+      response.sendStatus(404);
+      return;
+    }
+
+    /* Don't re-deal if player already has tiles. */
+    if (this.state.player_tiles[session_id]) {
+      response.json({ tiles: this.state.player_tiles[session_id] });
+      return;
+    }
+
+    /* Deal initial tiles. */
+    const tiles = [];
+    for (let i = 0; i < INITIAL_TILES && this.state.bag.length > 0; i++) {
+      tiles.push(this.state.bag.pop());
+    }
+    this.state.player_tiles[session_id] = tiles;
+
+    this.broadcast_event_object("player-joined-game", {
+      name: player.name
+    });
+
+    this.broadcast_event_object("dealt", {
+      remaining: this.state.bag.length
+    });
+
+    response.json({ tiles: tiles });
+  }
+
+  handle_stuck(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_tiles[session_id]) {
+      response.sendStatus(400);
+      return;
+    }
+
+    /* Toggle stuck flag. */
+    if (this.state.stuck.has(session_id)) {
+      this.state.stuck.delete(session_id);
+    } else {
+      this.state.stuck.add(session_id);
+    }
+
+    /* Broadcast who's stuck (by name). */
+    const stuck_names = [];
+    for (const sid of this.state.stuck) {
+      const p = this.players_by_session[sid];
+      if (p) stuck_names.push(p.name);
+    }
+    this.broadcast_event_object("stuck", { stuck: stuck_names });
+
+    response.json({ stuck: this.state.stuck.has(session_id) });
+
+    /* If all active players with tiles are stuck, deal new tiles. */
+    const active_sessions = Object.keys(this.state.player_tiles).filter(
+      sid => {
+        const p = this.players_by_session[sid];
+        return p && p.active;
+      }
+    );
+
+    if (active_sessions.length > 0 &&
+        active_sessions.every(sid => this.state.stuck.has(sid))) {
+      this.deal(2);
+    }
+  }
+
+  handle_complete(request, response) {
+    const session_id = request.session.id;
+    const player = this.players_by_session[session_id];
+
+    if (!player) {
+      response.sendStatus(404);
+      return;
+    }
+
+    const tiles = this.state.player_tiles[session_id];
+    if (!tiles || tiles.length === 0) {
+      response.sendStatus(400);
+      return;
+    }
+
+    if (this.state.finished) {
+      response.json({ finished: true, winner: this.state.winner });
+      return;
+    }
+
+    /* Try to deal 2 tiles to everyone. If we can't deal at all,
+     * this player wins. */
+    const dealt = this.deal(2);
+    if (!dealt) {
+      this.state.finished = true;
+      this.state.winner = player.name;
+      this.broadcast_event_object("game-over", {
+        winner: player.name
+      });
+      response.json({ finished: true, winner: player.name });
+      return;
+    }
+
+    response.json({ finished: false });
+  }
+
+  handle_events(request, response) {
+    super.handle_events(request, response);
+
+    const session_id = request.session.id;
+    const tiles = this.state.player_tiles[session_id];
+
+    /* Send this player's current tiles so reconnecting players
+     * recover their state. */
+    if (tiles) {
+      const data = JSON.stringify({ tiles: tiles });
+      response.write(`event: tiles\ndata: ${data}\n\n`);
+    }
+
+    /* Send current bag count. */
+    response.write(`event: dealt\ndata: ${JSON.stringify({
+      remaining: this.state.bag.length
+    })}\n\n`);
+
+    /* Send current stuck state. */
+    const stuck_names = [];
+    for (const sid of this.state.stuck) {
+      const p = this.players_by_session[sid];
+      if (p) stuck_names.push(p.name);
+    }
+    response.write(`event: stuck\ndata: ${JSON.stringify({
+      stuck: stuck_names
+    })}\n\n`);
+
+    /* Send game-over if finished. */
+    if (this.state.finished) {
+      response.write(`event: game-over\ndata: ${JSON.stringify({
+        winner: this.state.winner
+      })}\n\n`);
+    }
+  }
+}
+
+LetterRip.router = express.Router();
+const router = LetterRip.router;
+
+router.post('/join', (request, response) => {
+  request.game.handle_join(request, response);
+});
+
+router.post('/stuck', (request, response) => {
+  request.game.handle_stuck(request, response);
+});
+
+router.post('/complete', (request, response) => {
+  request.game.handle_complete(request, response);
+});
+
+LetterRip.meta = {
+  name: "Letter Rip",
+  identifier: "letterrip",
+  options: {
+    allow_guest: true
+  }
+};
+
+exports.Game = LetterRip;
diff --git a/lmno.js b/lmno.js
index a220ea2eb6fba0ddb25391bf258d1255320e8b46..5b0559bee278bef8f5957556eaec672e21b0f567 100644 (file)
--- a/lmno.js
+++ b/lmno.js
@@ -116,7 +116,8 @@ const engines = {
   empires: require("./empires").Game,
   tictactoe: require("./tictactoe").Game,
   scribe: require("./scribe").Game,
-  empathy: require("./empathy").Game
+  empathy: require("./empathy").Game,
+  letterrip: require("./letterrip").Game
 };
 
 class LMNO {
diff --git a/templates/letterrip-game.html b/templates/letterrip-game.html
new file mode 100644 (file)
index 0000000..acd1cb5
--- /dev/null
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block head %}
+<link rel="stylesheet" href="/letterrip/letterrip.css" type="text/css" />
+
+<script src="/react.js"></script>
+<script src="/react-dom.js"></script>
+<script src="/letterrip/twl.js"></script>
+<script type="module" src="/letterrip/letterrip.js"></script>
+{% endblock %}
+
+{% block page %}
+<h1><a href="/letterrip">Letter Rip</a></h1>
+
+<div id="letterrip"></div>
+
+{% endblock %}