From f78290305e077922141048b4991e0dd8ed517000 Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sat, 7 Mar 2026 22:40:25 -0500 Subject: [PATCH] Add Anagrams client-side UI 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 --- anagrams/.gitignore | 1 + anagrams/anagrams.css | 355 +++++++++++++++++ anagrams/anagrams.jsx | 890 ++++++++++++++++++++++++++++++++++++++++++ anagrams/index.html | 58 +++ index.html | 3 + 5 files changed, 1307 insertions(+) create mode 100644 anagrams/.gitignore create mode 100644 anagrams/anagrams.css create mode 100644 anagrams/anagrams.jsx create mode 100644 anagrams/index.html diff --git a/anagrams/.gitignore b/anagrams/.gitignore new file mode 100644 index 0000000..33c5b27 --- /dev/null +++ b/anagrams/.gitignore @@ -0,0 +1 @@ +anagrams.js diff --git a/anagrams/anagrams.css b/anagrams/anagrams.css new file mode 100644 index 0000000..c194c3c --- /dev/null +++ b/anagrams/anagrams.css @@ -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 index 0000000..8969fc1 --- /dev/null +++ b/anagrams/anagrams.jsx @@ -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 ( +
+

{props.id}

+ Invite friends to play: {props.url} +
+ ); +} + +function Tile(props) { + const { letter, className: extraClass, style, onClick, + onDragStart, onDragEnd, draggable } = props; + let className = "tile"; + if (extraClass) className += " " + extraClass; + + return ( +
+ {letter} +
+ ); +} + +function WordDisplay(props) { + const { word, stealable, onSteal } = props; + return ( +
+ {word.word.split("").map((letter, i) => ( + + ))} +
+ ); +} + +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 ( +
+
+

{player_name} claims:

+
{word}
+

Not in the dictionary. Accept it?

+ {is_submitter ? ( +

Waiting for other players to vote...

+ ) : ( +
+ + +
+ )} +
+ {votes_cast} of {voters_total} voted +
+
+
+ ); +} + +/********************************************************* + * 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 [ + , + + !state.joined ? ( +
+ +
+ ) : null, + + state.joined ? this.render_controls() : null, + + state.claim_player && !state.claim_active ? ( +
+ {state.claim_player} is forming a word... +
+ ) : 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 ? ( + 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 ( +
+ + + + + {state.letter_request_votes > 0 && state.bag_remaining > 0 ? ( + + {state.letter_request_votes}/{state.letter_request_needed} want a letter + + ) : null} +
+ ); + } + + render_center_pool() { + const { center, tile_positions, revealing, claim_active } = this.state; + + return ( +
+ {center.length === 0 ? ( +
+ No letters yet. Press the bag to request one. +
+ ) : 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 ( + this.take_letter(tile) : null} + /> + ); + })} +
+ ); + } + + 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 ( +
+

Your word:

+ + {claimed_words.length > 0 ? ( +
+ {claimed_words.map(cw => ( + + {cw.word_obj.word.split("").map((ch, i) => ( + + ))} + + + + + ))} +
+ ) : null} + +
+ {claim_rack.map(tile => ( + this.return_letter(tile)} + /> + ))} +
+ +
+ + + {claim_warning ? ( + + {Math.ceil(claim_remaining_ms / 1000)}s + + ) : null} +
+ + {claim_error ? ( +
{claim_error}
+ ) : null} +
+ ); + } + + 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 ( +
+ {sessions.map(sid => { + const pw = player_words[sid]; + const sc = scores[sid]; + const is_me = pw.name === player_info.name; + + return ( +
+

+ + {pw.name} + {sc ? ( + + ({sc.score} {sc.score === 1 ? "pt" : "pts"}) + + ) : null} + +

+ {pw.words.length === 0 ? ( + No words yet + ) : ( + pw.words.map(w => ( + this.steal_word(sid, w) : null} + /> + )) + )} +
+ ); + })} +
+ ); + } + + 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 [ + , +
+ Game Over! +
, +
+

Final Scores

+ {sorted.map((entry, i) => ( +
+ #{i + 1} + {entry.name} + + {entry.score} {entry.score === 1 ? "point" : "points"} + +
+ ))} + {sorted.map(entry => ( +
+

{entry.name}

+ {entry.words.map((w, i) => ( + + {w.split("").map((ch, j) => ( + + ))} + + ))} +
+ ))} +
, + game_steal ? ( +
+ The game steals {game_steal.words.map(w => w.word).join(" + ")} to + make {game_steal.result}! +
+ ) : null + ]; + } +} + +ReactDOM.render( 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 index 0000000..52ae5c9 --- /dev/null +++ b/anagrams/index.html @@ -0,0 +1,58 @@ + + + + + + + Anagrams + + + + + + + + +
+ +

Anagrams

+ +

+ 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. +

+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + diff --git a/index.html b/index.html index 149d64d..81a6b19 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,9 @@
  • Letter Rip
  • +
  • + Anagrams +
  • Strategy Games (2 players or teams)

    -- 2.45.2