]> git.cworth.org Git - lmno.games/commitdiff
Add Anagrams client-side UI
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 03:40:25 +0000 (22:40 -0500)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 12:45:57 +0000 (08:45 -0400)
React game component with center letter pool (random tile
positions), claim mode with rack for arranging words, player
word areas with steal support, voting modal for non-dictionary
words, and game-over scoreboard. Also adds lobby page and
listing on main index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
anagrams/.gitignore [new file with mode: 0644]
anagrams/anagrams.css [new file with mode: 0644]
anagrams/anagrams.jsx [new file with mode: 0644]
anagrams/index.html [new file with mode: 0644]
index.html

diff --git a/anagrams/.gitignore b/anagrams/.gitignore
new file mode 100644 (file)
index 0000000..33c5b27
--- /dev/null
@@ -0,0 +1 @@
+anagrams.js
diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css
new file mode 100644 (file)
index 0000000..c194c3c
--- /dev/null
@@ -0,0 +1,355 @@
+.game-info {
+  margin-bottom: 1em;
+}
+
+.game-info h2 {
+  display: inline;
+  margin-right: 0.5em;
+}
+
+/* Player list and scores */
+.player-list {
+  margin-bottom: 1em;
+}
+
+.player-entry {
+  margin-bottom: 0.5em;
+}
+
+.player-name-score {
+  font-weight: bold;
+}
+
+.player-score {
+  color: #27ae60;
+  margin-left: 0.3em;
+}
+
+/* Controls */
+.controls {
+  display: flex;
+  align-items: center;
+  gap: 1em;
+  margin-bottom: 1em;
+  flex-wrap: wrap;
+}
+
+.controls button {
+  padding: 0.4em 1em;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 1em;
+}
+
+.claim-btn {
+  background: #27ae60;
+  border: 2px solid #27ae60;
+  color: white;
+  font-size: 1.1em;
+  padding: 0.5em 1.5em;
+}
+
+.claim-btn:disabled {
+  background: #95a5a6;
+  border-color: #95a5a6;
+  cursor: default;
+}
+
+.claim-btn.queued {
+  background: #e67e22;
+  border-color: #e67e22;
+}
+
+.join-btn {
+  background: #3498db;
+  border: 2px solid #3498db;
+  color: white;
+  font-size: 1.1em;
+  padding: 0.5em 1.5em;
+}
+
+/* Bag button */
+.bag-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.3em;
+  padding: 0.4em 0.8em;
+  background: #8e6c3e;
+  border: 2px solid #6b4f2d;
+  border-radius: 6px;
+  color: white;
+  font-size: 0.95em;
+  cursor: pointer;
+}
+
+.bag-btn:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.bag-btn .bag-icon {
+  font-size: 1.2em;
+}
+
+/* Center letter pool */
+.center-pool {
+  position: relative;
+  min-height: 200px;
+  background: #fafaf5;
+  border: 2px solid #ddd;
+  border-radius: 8px;
+  margin-bottom: 1em;
+  padding: 1em;
+  overflow: hidden;
+}
+
+.center-pool .tile {
+  position: absolute;
+  cursor: grab;
+  transition: opacity 0.3s;
+}
+
+.center-pool .tile.revealing {
+  cursor: default;
+  background: #c9a96e;
+  color: transparent;
+}
+
+.center-pool .tile .countdown {
+  position: absolute;
+  color: white;
+  font-size: 18px;
+  font-weight: bold;
+}
+
+/* Claim rack (shown when claiming) */
+.claim-area {
+  margin-bottom: 1em;
+  padding: 0.5em;
+  background: #e8f5e9;
+  border: 2px solid #27ae60;
+  border-radius: 6px;
+}
+
+.claim-area h3 {
+  margin: 0 0 0.5em 0;
+  font-size: 1em;
+}
+
+.claim-rack {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  min-height: 52px;
+  padding: 4px;
+  background: #fff;
+  border: 2px dashed #bdc3c7;
+  border-radius: 4px;
+  margin-bottom: 0.5em;
+}
+
+.claim-rack.drag-over {
+  background: #d5f5e3;
+  border-color: #27ae60;
+}
+
+.claim-actions {
+  display: flex;
+  gap: 0.5em;
+  align-items: center;
+}
+
+.claim-actions .submit-btn {
+  background: #27ae60;
+  border: 2px solid #27ae60;
+  color: white;
+  padding: 0.3em 1em;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.claim-actions .submit-btn:disabled {
+  background: #95a5a6;
+  border-color: #95a5a6;
+  cursor: default;
+}
+
+.claim-actions .cancel-btn {
+  background: #e74c3c;
+  border: 2px solid #e74c3c;
+  color: white;
+  padding: 0.3em 1em;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.claim-timer {
+  font-size: 0.9em;
+  color: #e74c3c;
+  font-weight: bold;
+}
+
+.claim-error {
+  color: #e74c3c;
+  font-size: 0.9em;
+  margin-top: 0.3em;
+}
+
+/* Stolen word display in claim area */
+.claimed-word {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  margin-right: 0.5em;
+  margin-bottom: 4px;
+}
+
+.claimed-word .separator {
+  font-size: 18px;
+  font-weight: bold;
+  color: #666;
+  margin: 0 2px;
+}
+
+/* Player word areas */
+.player-words-area {
+  margin-bottom: 1em;
+}
+
+.player-word-section {
+  margin-bottom: 1em;
+  padding: 0.5em;
+  background: #f8f8f8;
+  border-radius: 6px;
+}
+
+.player-word-section h3 {
+  margin: 0 0 0.5em 0;
+  font-size: 1em;
+}
+
+.word-display {
+  display: inline-flex;
+  gap: 2px;
+  margin: 2px 4px 2px 0;
+  cursor: default;
+}
+
+.word-display.stealable {
+  cursor: grab;
+}
+
+.word-display .tile {
+  width: 32px;
+  height: 32px;
+  font-size: 16px;
+  cursor: inherit;
+}
+
+/* Vote modal */
+.vote-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.4);
+  z-index: 100;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.vote-modal {
+  background: white;
+  padding: 1.5em;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0,0,0,0.3);
+  text-align: center;
+  max-width: 320px;
+}
+
+.vote-modal h3 {
+  margin: 0 0 0.5em 0;
+}
+
+.vote-modal .word {
+  font-size: 1.5em;
+  font-weight: bold;
+  letter-spacing: 0.1em;
+  margin-bottom: 0.75em;
+}
+
+.vote-modal .vote-buttons {
+  display: flex;
+  gap: 1em;
+  justify-content: center;
+}
+
+.vote-modal .vote-btn {
+  font-size: 2em;
+  padding: 0.2em 0.5em;
+  border: 2px solid #ddd;
+  border-radius: 8px;
+  background: white;
+  cursor: pointer;
+}
+
+.vote-modal .vote-btn.accept {
+  border-color: #27ae60;
+}
+
+.vote-modal .vote-btn.reject {
+  border-color: #e74c3c;
+}
+
+.vote-modal .vote-status {
+  margin-top: 0.5em;
+  font-size: 0.9em;
+  color: #666;
+}
+
+/* Game over */
+.game-over-banner {
+  background: #27ae60;
+  color: white;
+  padding: 1em;
+  border-radius: 6px;
+  text-align: center;
+  margin-bottom: 1em;
+  font-size: 1.2em;
+}
+
+.final-scores {
+  margin-bottom: 1em;
+}
+
+.final-score-line {
+  display: flex;
+  align-items: baseline;
+  gap: 0.5em;
+  margin-bottom: 0.3em;
+  font-size: 1.1em;
+}
+
+.final-score-line .rank {
+  font-weight: bold;
+  color: #666;
+}
+
+/* Status messages */
+.status {
+  margin-bottom: 1em;
+  font-style: italic;
+  color: #555;
+}
+
+/* Notification for claim activity */
+.claim-notification {
+  background: #fff3cd;
+  border: 1px solid #ffc107;
+  border-radius: 4px;
+  padding: 0.5em 1em;
+  margin-bottom: 1em;
+  font-size: 0.95em;
+}
diff --git a/anagrams/anagrams.jsx b/anagrams/anagrams.jsx
new file mode 100644 (file)
index 0000000..8969fc1
--- /dev/null
@@ -0,0 +1,890 @@
+/*********************************************************
+ * Utility functions                                     *
+ *********************************************************/
+
+function fetch_post_json(api = '', data = {}) {
+  return fetch(api, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data)
+  });
+}
+
+/*********************************************************
+ * React Components                                      *
+ *********************************************************/
+
+function GameInfo(props) {
+  if (!props.id) return null;
+  return (
+    <div className="game-info">
+      <h2>{props.id}</h2>
+      Invite friends to play: {props.url}
+    </div>
+  );
+}
+
+function Tile(props) {
+  const { letter, className: extraClass, style, onClick,
+          onDragStart, onDragEnd, draggable } = props;
+  let className = "tile";
+  if (extraClass) className += " " + extraClass;
+
+  return (
+    <div className={className}
+         style={style}
+         draggable={draggable || false}
+         onDragStart={onDragStart}
+         onDragEnd={onDragEnd}
+         onClick={onClick}>
+      {letter}
+    </div>
+  );
+}
+
+function WordDisplay(props) {
+  const { word, stealable, onSteal } = props;
+  return (
+    <div className={"word-display" + (stealable ? " stealable" : "")}
+         draggable={stealable}
+         onDragStart={stealable ? onSteal : null}>
+      {word.word.split("").map((letter, i) => (
+        <Tile key={i} letter={letter} />
+      ))}
+    </div>
+  );
+}
+
+function VoteModal(props) {
+  const { word, player_name, my_session, submitter_session,
+          votes_cast, voters_total, onVote } = props;
+
+  const is_submitter = my_session === submitter_session;
+
+  return (
+    <div className="vote-overlay">
+      <div className="vote-modal">
+        <h3>{player_name} claims:</h3>
+        <div className="word">{word}</div>
+        <p>Not in the dictionary. Accept it?</p>
+        {is_submitter ? (
+          <p className="vote-status">Waiting for other players to vote...</p>
+        ) : (
+          <div className="vote-buttons">
+            <button className="vote-btn accept" onClick={() => onVote(true)}>
+              &#x1F44D;
+            </button>
+            <button className="vote-btn reject" onClick={() => onVote(false)}>
+              &#x1F44E;
+            </button>
+          </div>
+        )}
+        <div className="vote-status">
+          {votes_cast} of {voters_total} voted
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/*********************************************************
+ * Main Game Component                                   *
+ *********************************************************/
+
+class Game extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      game_info: {},
+      player_info: {},
+      other_players: [],
+      joined: false,
+      /* Center pool letters */
+      center: [],
+      revealing: {},  /* letter_id -> countdown seconds remaining */
+      /* Player words: { session_id: { name, words: [...] } } */
+      player_words: {},
+      /* Scores: { session_id: { name, score, words } } */
+      scores: {},
+      /* Bag */
+      bag_remaining: null,
+      letter_request_votes: 0,
+      letter_request_needed: 0,
+      /* Claim state */
+      claiming: false,
+      claim_active: false,      /* true if I'm the active claimer */
+      claim_player: null,       /* name of the current claimer */
+      claim_rack: [],           /* tiles I've claimed (in my order) */
+      claimed_words: [],        /* word objects I've stolen */
+      claim_error: null,
+      claim_warning: false,
+      claim_remaining_ms: 0,
+      /* Voting */
+      vote_pending: null,
+      my_vote: null,
+      votes_cast: 0,
+      voters_total: 0,
+      /* Game over */
+      game_over: false,
+      final_scores: null,
+      game_steal: null,
+      /* Tile positions in center (for random placement) */
+      tile_positions: {}
+    };
+    this._claim_interval = null;
+  }
+
+  /*****************************************************
+   * SSE event handlers                                *
+   *****************************************************/
+
+  set_game_info(info) {
+    this.setState({ game_info: info });
+  }
+
+  set_player_info(info) {
+    this.setState({ player_info: info });
+  }
+
+  set_other_player_info(info) {
+    const others = [...this.state.other_players];
+    const idx = others.findIndex(o => o.id === info.id);
+    if (idx >= 0) others[idx] = info;
+    else others.push(info);
+    this.setState({ other_players: others });
+  }
+
+  remove_other_player(info) {
+    this.setState({
+      other_players: this.state.other_players.filter(o => o.id !== info.id)
+    });
+  }
+
+  receive_center(tiles) {
+    const positions = { ...this.state.tile_positions };
+    for (const tile of tiles) {
+      if (!positions[tile.id]) {
+        positions[tile.id] = this._random_position();
+      }
+    }
+    this.setState({ center: tiles, tile_positions: positions });
+  }
+
+  receive_letter_reveal(data) {
+    const { tile, remaining, countdown_ms } = data;
+    const center = [...this.state.center];
+    /* Tile was already added server-side; update if present, else add. */
+    if (!center.find(t => t.id === tile.id)) {
+      center.push(tile);
+    }
+    const positions = { ...this.state.tile_positions };
+    if (!positions[tile.id]) {
+      positions[tile.id] = this._random_position();
+    }
+    const revealing = { ...this.state.revealing };
+    revealing[tile.id] = Math.ceil(countdown_ms / 1000);
+
+    this.setState({
+      center,
+      tile_positions: positions,
+      revealing,
+      bag_remaining: remaining,
+      letter_request_votes: 0
+    });
+
+    /* Tick down the countdown. */
+    const tick = () => {
+      this.setState(prev => {
+        const r = { ...prev.revealing };
+        if (r[tile.id] !== undefined) {
+          r[tile.id]--;
+          if (r[tile.id] <= 0) delete r[tile.id];
+        }
+        return { revealing: r };
+      });
+    };
+    for (let i = 1; i <= Math.ceil(countdown_ms / 1000); i++) {
+      setTimeout(tick, i * 1000);
+    }
+  }
+
+  receive_bag_count(data) {
+    this.setState({ bag_remaining: data.remaining });
+  }
+
+  receive_letter_request(data) {
+    this.setState({
+      letter_request_votes: data.votes,
+      letter_request_needed: data.needed
+    });
+  }
+
+  receive_player_words(data) {
+    this.setState({ player_words: data });
+  }
+
+  receive_scores(data) {
+    this.setState({ scores: data });
+  }
+
+  receive_claim_start(data) {
+    const my_session = this.state.player_info.id;
+    const is_me = data.player_name === this.state.player_info.name;
+    this.setState({
+      claim_player: data.player_name,
+      claim_active: is_me,
+      claiming: is_me,
+      claim_rack: is_me ? [] : this.state.claim_rack,
+      claimed_words: is_me ? [] : this.state.claimed_words,
+      claim_error: null,
+      claim_warning: false,
+      claim_remaining_ms: data.timeout_ms
+    });
+
+    if (is_me && data.timeout_ms > 0) {
+      this._start_claim_timer(data.timeout_ms, data.warning_ms);
+    }
+  }
+
+  receive_claim_end(data) {
+    this.setState({
+      claim_player: null,
+      claim_active: false,
+      claiming: false,
+      claim_rack: [],
+      claimed_words: [],
+      claim_error: null,
+      claim_warning: false,
+      claim_remaining_ms: 0
+    });
+    this._stop_claim_timer();
+  }
+
+  receive_letter_claimed(data) {
+    /* Remove from center. */
+    this.setState(prev => ({
+      center: prev.center.filter(t => t.id !== data.tile.id)
+    }));
+  }
+
+  receive_letter_returned(data) {
+    /* Add back to center. */
+    this.setState(prev => {
+      const positions = { ...prev.tile_positions };
+      if (!positions[data.tile.id]) {
+        positions[data.tile.id] = this._random_position();
+      }
+      return {
+        center: [...prev.center, data.tile],
+        tile_positions: positions
+      };
+    });
+  }
+
+  receive_word_stolen(data) {
+    /* Player words update will come via player-words event. */
+  }
+
+  receive_word_returned(data) {
+    /* Player words update will come via player-words event. */
+  }
+
+  receive_word_accepted(data) {
+    /* Updated state comes via player-words and scores events. */
+  }
+
+  receive_claim_warning(data) {
+    this.setState({ claim_warning: true });
+  }
+
+  receive_vote_start(data) {
+    this.setState({
+      vote_pending: {
+        player_name: data.player_name,
+        word: data.word
+      },
+      my_vote: null,
+      votes_cast: 0,
+      voters_total: this.state.other_players.length
+    });
+  }
+
+  receive_vote_update(data) {
+    this.setState({
+      votes_cast: data.votes_cast,
+      voters_total: data.voters_total
+    });
+  }
+
+  receive_vote_result(data) {
+    this.setState({ vote_pending: null, my_vote: null });
+  }
+
+  receive_game_over(data) {
+    this.setState({
+      game_over: true,
+      final_scores: data.scores,
+      game_steal: data.game_steal
+    });
+  }
+
+  /*****************************************************
+   * Actions                                           *
+   *****************************************************/
+
+  async join_game() {
+    const response = await fetch_post_json("join");
+    if (response.ok) {
+      const data = await response.json();
+      this.setState({
+        joined: true,
+        center: data.center,
+        player_words: data.player_words,
+        scores: data.scores,
+        bag_remaining: data.remaining
+      });
+      /* Initialize positions for center tiles. */
+      const positions = {};
+      for (const tile of data.center) {
+        positions[tile.id] = this._random_position();
+      }
+      this.setState({ tile_positions: positions });
+    }
+  }
+
+  async request_letter() {
+    await fetch_post_json("request-letter");
+  }
+
+  async start_claim() {
+    const response = await fetch_post_json("claim");
+    if (response.ok) {
+      const data = await response.json();
+      this.setState({ claiming: data.queued });
+    }
+  }
+
+  async take_letter(tile) {
+    const response = await fetch_post_json("take-letter", {
+      letter_id: tile.id
+    });
+    if (response.ok) {
+      this.setState(prev => ({
+        claim_rack: [...prev.claim_rack, tile],
+        center: prev.center.filter(t => t.id !== tile.id),
+        claim_error: null
+      }));
+    }
+  }
+
+  async return_letter(tile) {
+    const response = await fetch_post_json("return-letter", {
+      letter_id: tile.id
+    });
+    if (response.ok) {
+      const positions = { ...this.state.tile_positions };
+      if (!positions[tile.id]) {
+        positions[tile.id] = this._random_position();
+      }
+      this.setState(prev => ({
+        claim_rack: prev.claim_rack.filter(t => t.id !== tile.id),
+        center: [...prev.center, tile],
+        tile_positions: positions,
+        claim_error: null
+      }));
+    }
+  }
+
+  async steal_word(owner_session, word_obj) {
+    const response = await fetch_post_json("steal-word", {
+      owner_session,
+      word_id: word_obj.id
+    });
+    if (response.ok) {
+      this.setState(prev => ({
+        claimed_words: [...prev.claimed_words, {
+          owner_session,
+          word_id: word_obj.id,
+          word_obj
+        }],
+        claim_error: null
+      }));
+    }
+  }
+
+  async return_word(word_id) {
+    const response = await fetch_post_json("return-word", { word_id });
+    if (response.ok) {
+      this.setState(prev => ({
+        claimed_words: prev.claimed_words.filter(cw => cw.word_id !== word_id)
+      }));
+    }
+  }
+
+  async submit_word() {
+    /* Build the word from claim_rack order. */
+    const word = this.state.claim_rack.map(t => t.letter).join("");
+    const response = await fetch_post_json("submit", { word });
+    if (response.ok) {
+      const data = await response.json();
+      if (!data.ok) {
+        this.setState({ claim_error: data.error });
+      } else if (data.voting) {
+        /* Voting started — handled by SSE. */
+      }
+      /* If accepted, claim-end event will handle cleanup. */
+    }
+  }
+
+  async cancel_claim() {
+    await fetch_post_json("cancel-claim");
+    this.setState({
+      claiming: false,
+      claim_active: false,
+      claim_rack: [],
+      claimed_words: [],
+      claim_error: null
+    });
+  }
+
+  async vote(accept) {
+    this.setState({ my_vote: accept });
+    await fetch_post_json("vote", { accept });
+  }
+
+  async mark_done() {
+    await fetch_post_json("done");
+  }
+
+  /*****************************************************
+   * Claim timer                                       *
+   *****************************************************/
+
+  _start_claim_timer(timeout_ms, warning_ms) {
+    this._stop_claim_timer();
+    const end = Date.now() + timeout_ms;
+    const warn_at = end - warning_ms;
+    this._claim_interval = setInterval(() => {
+      const remaining = end - Date.now();
+      if (remaining <= 0) {
+        this._stop_claim_timer();
+        return;
+      }
+      this.setState({
+        claim_remaining_ms: remaining,
+        claim_warning: Date.now() >= warn_at
+      });
+    }, 250);
+  }
+
+  _stop_claim_timer() {
+    if (this._claim_interval) {
+      clearInterval(this._claim_interval);
+      this._claim_interval = null;
+    }
+  }
+
+  /*****************************************************
+   * Helpers                                           *
+   *****************************************************/
+
+  _random_position() {
+    /* Returns {x, y} as percentages within the center pool,
+     * keeping tiles away from edges. */
+    return {
+      x: 5 + Math.random() * 80,
+      y: 5 + Math.random() * 75
+    };
+  }
+
+  /*****************************************************
+   * Render                                            *
+   *****************************************************/
+
+  render() {
+    const state = this.state;
+
+    if (state.game_over && state.final_scores) {
+      return this.render_game_over();
+    }
+
+    return [
+      <GameInfo key="gi" id={state.game_info.id}
+                url={state.game_info.url} />,
+
+      !state.joined ? (
+        <div key="join" className="controls">
+          <button className="join-btn"
+                  onClick={() => this.join_game()}>
+            Join Game
+          </button>
+        </div>
+      ) : null,
+
+      state.joined ? this.render_controls() : null,
+
+      state.claim_player && !state.claim_active ? (
+        <div key="notif" className="claim-notification">
+          {state.claim_player} is forming a word...
+        </div>
+      ) : null,
+
+      state.joined && state.claim_active ? this.render_claim_area() : null,
+
+      state.joined ? this.render_center_pool() : null,
+
+      state.joined ? this.render_player_words() : null,
+
+      state.vote_pending ? (
+        <VoteModal key="vote"
+          word={state.vote_pending.word}
+          player_name={state.vote_pending.player_name}
+          my_session={state.player_info.id}
+          submitter_session={null}
+          votes_cast={state.votes_cast}
+          voters_total={state.voters_total}
+          onVote={(accept) => this.vote(accept)}
+        />
+      ) : null
+    ];
+  }
+
+  render_controls() {
+    const state = this.state;
+    const can_claim = !state.claiming && !state.claim_player
+                      && !state.game_over;
+    const is_queued = state.claiming && !state.claim_active;
+
+    return (
+      <div key="ctrl" className="controls">
+        <button className={"claim-btn" + (is_queued ? " queued" : "")}
+                disabled={!can_claim && !is_queued}
+                onClick={() => this.start_claim()}>
+          {is_queued ? "Queued..." :
+           state.claim_player ? state.claim_player + " is claiming" :
+           "Claim a Word"}
+        </button>
+
+        <button className="bag-btn"
+                disabled={state.bag_remaining === 0 || !!state.claim_player}
+                onClick={() => this.request_letter()}>
+          <span className="bag-icon">&#x1F45C;</span>
+          {state.bag_remaining !== null ? state.bag_remaining : "?"}
+        </button>
+
+        {state.letter_request_votes > 0 && state.bag_remaining > 0 ? (
+          <span className="status">
+            {state.letter_request_votes}/{state.letter_request_needed} want a letter
+          </span>
+        ) : null}
+      </div>
+    );
+  }
+
+  render_center_pool() {
+    const { center, tile_positions, revealing, claim_active } = this.state;
+
+    return (
+      <div key="center" className="center-pool">
+        {center.length === 0 ? (
+          <div className="status">
+            No letters yet. Press the bag to request one.
+          </div>
+        ) : null}
+        {center.map(tile => {
+          const pos = tile_positions[tile.id] || { x: 50, y: 50 };
+          const is_revealing = revealing[tile.id] !== undefined;
+          const style = {
+            left: pos.x + "%",
+            top: pos.y + "%"
+          };
+
+          return (
+            <Tile key={tile.id}
+                  letter={is_revealing ? revealing[tile.id] : tile.letter}
+                  className={is_revealing ? "revealing" : ""}
+                  style={style}
+                  draggable={claim_active && !is_revealing}
+                  onClick={claim_active && !is_revealing
+                    ? () => this.take_letter(tile) : null}
+            />
+          );
+        })}
+      </div>
+    );
+  }
+
+  render_claim_area() {
+    const { claim_rack, claimed_words, claim_error,
+            claim_warning, claim_remaining_ms } = this.state;
+
+    const total_letters = claim_rack.length;
+    const can_submit = total_letters >= 4;
+
+    return (
+      <div key="claim" className="claim-area">
+        <h3>Your word:</h3>
+
+        {claimed_words.length > 0 ? (
+          <div>
+            {claimed_words.map(cw => (
+              <span key={cw.word_id} className="claimed-word">
+                {cw.word_obj.word.split("").map((ch, i) => (
+                  <Tile key={i} letter={ch} />
+                ))}
+                <span className="separator">+</span>
+                <button onClick={() => this.return_word(cw.word_id)}
+                        style={{ cursor: "pointer", background: "none",
+                                 border: "none", fontSize: "1.2em",
+                                 color: "#e74c3c" }}>
+                  &#x2715;
+                </button>
+              </span>
+            ))}
+          </div>
+        ) : null}
+
+        <div className="claim-rack">
+          {claim_rack.map(tile => (
+            <Tile key={tile.id}
+                  letter={tile.letter}
+                  onClick={() => this.return_letter(tile)}
+            />
+          ))}
+        </div>
+
+        <div className="claim-actions">
+          <button className="submit-btn"
+                  disabled={!can_submit}
+                  onClick={() => this.submit_word()}>
+            Submit
+          </button>
+          <button className="cancel-btn"
+                  onClick={() => this.cancel_claim()}>
+            Cancel
+          </button>
+          {claim_warning ? (
+            <span className="claim-timer">
+              {Math.ceil(claim_remaining_ms / 1000)}s
+            </span>
+          ) : null}
+        </div>
+
+        {claim_error ? (
+          <div className="claim-error">{claim_error}</div>
+        ) : null}
+      </div>
+    );
+  }
+
+  render_player_words() {
+    const { player_words, scores, player_info, claim_active } = this.state;
+
+    const sessions = Object.keys(player_words);
+    if (sessions.length === 0) return null;
+
+    return (
+      <div key="words" className="player-words-area">
+        {sessions.map(sid => {
+          const pw = player_words[sid];
+          const sc = scores[sid];
+          const is_me = pw.name === player_info.name;
+
+          return (
+            <div key={sid} className="player-word-section">
+              <h3>
+                <span className="player-name-score">
+                  {pw.name}
+                  {sc ? (
+                    <span className="player-score">
+                      ({sc.score} {sc.score === 1 ? "pt" : "pts"})
+                    </span>
+                  ) : null}
+                </span>
+              </h3>
+              {pw.words.length === 0 ? (
+                <span className="status">No words yet</span>
+              ) : (
+                pw.words.map(w => (
+                  <WordDisplay key={w.id}
+                    word={w}
+                    stealable={claim_active}
+                    onSteal={claim_active
+                      ? () => this.steal_word(sid, w) : null}
+                  />
+                ))
+              )}
+            </div>
+          );
+        })}
+      </div>
+    );
+  }
+
+  render_game_over() {
+    const { final_scores, game_steal, game_info } = this.state;
+
+    const sorted = Object.values(final_scores)
+      .sort((a, b) => b.score - a.score);
+
+    return [
+      <GameInfo key="gi" id={game_info.id} url={game_info.url} />,
+      <div key="banner" className="game-over-banner">
+        Game Over!
+      </div>,
+      <div key="scores" className="final-scores">
+        <h2>Final Scores</h2>
+        {sorted.map((entry, i) => (
+          <div key={entry.name} className="final-score-line">
+            <span className="rank">#{i + 1}</span>
+            <span className="player-name-score">{entry.name}</span>
+            <span className="player-score">
+              {entry.score} {entry.score === 1 ? "point" : "points"}
+            </span>
+          </div>
+        ))}
+        {sorted.map(entry => (
+          <div key={entry.name + "-words"} className="player-word-section">
+            <h3>{entry.name}</h3>
+            {entry.words.map((w, i) => (
+              <span key={i} className="word-display">
+                {w.split("").map((ch, j) => (
+                  <Tile key={j} letter={ch} />
+                ))}
+              </span>
+            ))}
+          </div>
+        ))}
+      </div>,
+      game_steal ? (
+        <div key="steal" className="claim-notification">
+          The game steals {game_steal.words.map(w => w.word).join(" + ")} to
+          make <strong>{game_steal.result}</strong>!
+        </div>
+      ) : null
+    ];
+  }
+}
+
+ReactDOM.render(<Game ref={(me) => window.game = me} />,
+                document.getElementById("anagrams"));
+
+/*********************************************************
+ * Server-sent event stream                              *
+ *********************************************************/
+
+const events = new EventSource("events");
+
+events.onerror = function(event) {
+  if (event.target.readyState === EventSource.CLOSED) {
+    add_message("danger", "Connection to server lost.");
+  }
+};
+
+events.addEventListener("game-info", event => {
+  window.game.set_game_info(JSON.parse(event.data));
+});
+
+events.addEventListener("player-info", event => {
+  window.game.set_player_info(JSON.parse(event.data));
+});
+
+events.addEventListener("player-enter", event => {
+  window.game.set_other_player_info(JSON.parse(event.data));
+});
+
+events.addEventListener("player-update", event => {
+  const info = JSON.parse(event.data);
+  if (info.id === window.game.state.player_info.id)
+    window.game.set_player_info(info);
+  else
+    window.game.set_other_player_info(info);
+});
+
+events.addEventListener("player-exit", event => {
+  window.game.remove_other_player(JSON.parse(event.data));
+});
+
+events.addEventListener("center", event => {
+  window.game.receive_center(JSON.parse(event.data));
+});
+
+events.addEventListener("letter-reveal", event => {
+  window.game.receive_letter_reveal(JSON.parse(event.data));
+});
+
+events.addEventListener("bag-count", event => {
+  window.game.receive_bag_count(JSON.parse(event.data));
+});
+
+events.addEventListener("letter-request", event => {
+  window.game.receive_letter_request(JSON.parse(event.data));
+});
+
+events.addEventListener("player-words", event => {
+  window.game.receive_player_words(JSON.parse(event.data));
+});
+
+events.addEventListener("scores", event => {
+  window.game.receive_scores(JSON.parse(event.data));
+});
+
+events.addEventListener("claim-start", event => {
+  window.game.receive_claim_start(JSON.parse(event.data));
+});
+
+events.addEventListener("claim-end", event => {
+  window.game.receive_claim_end(JSON.parse(event.data));
+});
+
+events.addEventListener("letter-claimed", event => {
+  window.game.receive_letter_claimed(JSON.parse(event.data));
+});
+
+events.addEventListener("letter-returned", event => {
+  window.game.receive_letter_returned(JSON.parse(event.data));
+});
+
+events.addEventListener("word-stolen", event => {
+  window.game.receive_word_stolen(JSON.parse(event.data));
+});
+
+events.addEventListener("word-returned", event => {
+  window.game.receive_word_returned(JSON.parse(event.data));
+});
+
+events.addEventListener("word-accepted", event => {
+  window.game.receive_word_accepted(JSON.parse(event.data));
+});
+
+events.addEventListener("claim-warning", event => {
+  window.game.receive_claim_warning(JSON.parse(event.data));
+});
+
+events.addEventListener("claim-queued", event => {
+  /* Just a notification, no state change needed. */
+});
+
+events.addEventListener("vote-start", event => {
+  window.game.receive_vote_start(JSON.parse(event.data));
+});
+
+events.addEventListener("vote-update", event => {
+  window.game.receive_vote_update(JSON.parse(event.data));
+});
+
+events.addEventListener("vote-result", event => {
+  window.game.receive_vote_result(JSON.parse(event.data));
+});
+
+events.addEventListener("done-update", event => {
+  /* Could show progress, but game-over handles the end. */
+});
+
+events.addEventListener("game-over", event => {
+  window.game.receive_game_over(JSON.parse(event.data));
+});
+
+events.addEventListener("game-state", event => {
+  /* Not used for anagrams. */
+});
diff --git a/anagrams/index.html b/anagrams/index.html
new file mode 100644 (file)
index 0000000..52ae5c9
--- /dev/null
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>Anagrams</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>Anagrams</h1>
+
+      <p>
+        Race to form words from a shared pool of letters. Steal
+        words from other players by anagramming them into longer
+        words. The longer the word, the more points it scores.
+      </p>
+
+      <div id="message-area">
+      </div>
+
+      <form onsubmit="lmno_join(this); return false">
+
+        <div class="form-field large">
+          <label for="id">Game ID</label>
+          <input type="text" id="id" maxlength="4"
+                 placeholder="Enter a 4-letter Game Code"
+                 oninput="this.value = this.value.toUpperCase()"
+                 autocomplete="off"
+                 required
+                 autofocus>
+        </div>
+
+        <div class="form-field large">
+          <button type="submit">
+            Join Game
+          </button>
+        </div>
+
+      </form>
+
+      <form onsubmit="lmno_new('anagrams'); return false;">
+        <button type="submit">
+          Host a new game
+        </button>
+      </form>
+
+    </div>
+
+  </body>
+</html>
index 149d64dde51ff8299465d1bd025978d1d28a7350..81a6b199e619ffd71b39dc0158b189797224a3f8 100644 (file)
@@ -66,6 +66,9 @@
         <li>
           <a href="letterrip">Letter Rip</a>
         </li>
+        <li>
+          <a href="anagrams">Anagrams</a>
+        </li>
       </ul>
 
       <h2>Strategy Games (2 players or teams)</h2>