--- /dev/null
+/*********************************************************
+ * 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)}>
+ 👍
+ </button>
+ <button className="vote-btn reject" onClick={() => onVote(false)}>
+ 👎
+ </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">👜</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" }}>
+ ✕
+ </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. */
+});