1 const MAX_PROMPT_ITEMS = 20;
3 function undisplay(element) {
4 element.style.display="none";
7 function add_message(severity, message) {
8 message = `<div class="message ${severity}" onclick="undisplay(this)">
9 <span class="hide-button" onclick="undisplay(this.parentElement)">×</span>
12 const message_area = document.getElementById('message-area');
13 message_area.insertAdjacentHTML('beforeend', message);
16 /*********************************************************
17 * Handling server-sent event stream *
18 *********************************************************/
20 const events = new EventSource("events");
22 events.onerror = function(event) {
23 if (event.target.readyState === EventSource.CLOSED) {
25 add_message("danger", "Connection to server lost.");
30 events.addEventListener("game-info", event => {
31 const info = JSON.parse(event.data);
33 window.game.set_game_info(info);
36 events.addEventListener("player-info", event => {
37 const info = JSON.parse(event.data);
39 window.game.set_player_info(info);
42 events.addEventListener("player-enter", event => {
43 const info = JSON.parse(event.data);
45 window.game.set_other_player_info(info);
48 events.addEventListener("player-exit", event => {
49 const info = JSON.parse(event.data);
51 window.game.remove_player(info);
54 events.addEventListener("player-update", event => {
55 const info = JSON.parse(event.data);
57 if (info.id === window.game.state.player_info.id)
58 window.game.set_player_info(info);
60 window.game.set_other_player_info(info);
63 events.addEventListener("game-state", event => {
64 const state = JSON.parse(event.data);
66 window.game.reset_game_state();
68 window.game.set_prompts(state.prompts);
70 window.game.set_active_prompt(state.active_prompt);
72 window.game.set_players_answered(state.players_answered);
74 window.game.set_players_answering(state.players_answering);
76 window.game.set_answering_idle(state.answering_idle);
78 window.game.set_end_answers(state.end_answers);
80 window.game.set_ambiguities(state.ambiguities);
82 window.game.set_players_judged(state.players_judged);
84 window.game.set_players_judging(state.players_judging);
86 window.game.set_judging_idle(state.judging_idle);
88 window.game.set_end_judging(state.end_judging);
90 window.game.set_scores(state.scores);
92 window.game.set_new_game_votes(state.new_game_votes);
94 window.game.state_ready();
97 events.addEventListener("prompt", event => {
98 const prompt = JSON.parse(event.data);
100 window.game.add_or_update_prompt(prompt);
103 events.addEventListener("start", event => {
104 const prompt = JSON.parse(event.data);
106 window.game.set_active_prompt(prompt);
109 events.addEventListener("player-answered", event => {
110 const player = JSON.parse(event.data);
112 window.game.set_player_answered(player);
115 events.addEventListener("player-answering", event => {
116 const player = JSON.parse(event.data);
118 window.game.set_player_answering(player);
121 events.addEventListener("answering-idle", event => {
122 const value = JSON.parse(event.data);
124 window.game.set_answering_idle(value);
127 events.addEventListener("vote-end-answers", event => {
128 const player = JSON.parse(event.data);
130 window.game.set_player_vote_end_answers(player);
133 events.addEventListener("unvote-end-answers", event => {
134 const player = JSON.parse(event.data);
136 window.game.set_player_unvote_end_answers(player);
139 events.addEventListener("ambiguities", event => {
140 const ambiguities = JSON.parse(event.data);
142 window.game.set_ambiguities(ambiguities);
145 events.addEventListener("player-judged", event => {
146 const player = JSON.parse(event.data);
148 window.game.set_player_judged(player);
151 events.addEventListener("player-judging", event => {
152 const player = JSON.parse(event.data);
154 window.game.set_player_judging(player);
157 events.addEventListener("judging-idle", event => {
158 const value = JSON.parse(event.data);
160 window.game.set_judging_idle(value);
163 events.addEventListener("vote-end-judging", event => {
164 const player = JSON.parse(event.data);
166 window.game.set_player_vote_end_judging(player);
169 events.addEventListener("unvote-end-judging", event => {
170 const player = JSON.parse(event.data);
172 window.game.set_player_unvote_end_judging(player);
175 events.addEventListener("scores", event => {
176 const scores = JSON.parse(event.data);
178 window.game.set_scores(scores);
181 events.addEventListener("vote-new-game", event => {
182 const player = JSON.parse(event.data);
184 window.game.set_player_vote_new_game(player);
187 events.addEventListener("unvote-new-game", event => {
188 const player = JSON.parse(event.data);
190 window.game.set_player_unvote_new_game(player);
193 /*********************************************************
194 * Game and supporting classes *
195 *********************************************************/
197 function copy_to_clipboard(id)
199 const tmp = document.createElement("input");
200 tmp.setAttribute("value", document.getElementById(id).innerHTML);
201 document.body.appendChild(tmp);
203 document.execCommand("copy");
204 document.body.removeChild(tmp);
207 const GameInfo = React.memo(props => {
212 <div className="game-info">
213 <span className="game-id">{props.id}</span>
215 Share this link to invite friends:{" "}
216 <span id="game-share-url">{props.url}</span>
220 onClick={() => copy_to_clipboard('game-share-url')}
226 const PlayerInfo = React.memo(props => {
227 if (! props.player.id)
230 const all_players = [props.player, ...props.other_players];
232 const sorted_players = all_players.sort((a,b) => {
233 return b.score - a.score;
236 const names_and_scores = sorted_players.map(player => {
238 return `${player.name} (${player.score})`;
244 <div className="player-info">
245 <span className="players-header">Players: </span>
246 <span>{names_and_scores}</span>
251 function fetch_method_json(method, api = '', data = {}) {
252 const response = fetch(api, {
255 'Content-Type': 'application/json'
257 body: JSON.stringify(data)
262 function fetch_post_json(api = '', data = {}) {
263 return fetch_method_json('POST', api, data);
266 async function fetch_put_json(api = '', data = {}) {
267 return fetch_method_json('PUT', api, data);
270 class CategoryRequest extends React.PureComponent {
273 this.category = React.createRef();
275 this.handle_change = this.handle_change.bind(this);
276 this.handle_submit = this.handle_submit.bind(this);
279 handle_change(event) {
280 const category_input = this.category.current;
281 const category = category_input.value;
283 const match = category.match(/[0-9]+/);
285 const num_items = parseInt(match[0], 10);
286 if (num_items > 0 && num_items <= MAX_PROMPT_ITEMS)
287 category_input.setCustomValidity("");
291 async handle_submit(event) {
292 const form = event.currentTarget;
293 const category_input = this.category.current;
294 const category = category_input.value;
296 /* Prevent the default page-changing form-submission behavior. */
297 event.preventDefault();
299 const match = category.match(/[0-9]+/);
300 if (match === null) {
301 category_input.setCustomValidity("Category must include a number");
302 form.reportValidity();
306 const num_items = parseInt(match[0], 10);
308 if (num_items > MAX_PROMPT_ITEMS) {
309 category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
310 form.reportValidity();
315 category_input.setCustomValidity("Category must require at least one item.");
316 form.reportValidity();
320 const response = await fetch_post_json("prompts", {
325 if (response.status === 200) {
326 const result = await response.json();
327 if (! result.valid) {
328 add_message("danger", result.message);
332 add_message("danger", "An error occurred submitting your category");
340 <div className="category-request">
341 <h2>Submit a Category</h2>
343 Suggest a category to play. Don't forget to include the
344 number of items for each person to submit.
347 <form onSubmit={this.handle_submit} >
348 <div className="form-field large">
352 placeholder="6 things at the beach"
355 onChange={this.handle_change}
360 <div className="form-field large">
361 <button type="submit">
372 const PromptOption = React.memo(props => {
374 const prompt = props.prompt;
376 if (prompt.votes_against.find(v => v === props.player.name))
381 className="vote-button"
383 onClick={() => fetch_post_json(`vote/${prompt.id}`) }
386 className="hide-button"
387 onClick={(event) => {
388 event.stopPropagation();
389 fetch_post_json(`vote_against/${prompt.id}`);
395 <div className="vote-choices">
396 {prompt.votes.map(v => {
400 className="vote-choice"
411 const PromptOptions = React.memo(props => {
413 if (props.prompts.length === 0)
417 <div className="prompt-options">
418 <h2>Vote on Categories</h2>
420 Select any categories below that you'd like to play.
421 You can choose as many as you'd like.
424 prompt => <PromptOption
427 player={props.player}
434 const LetsPlay = React.memo(props => {
436 const quorum = Math.max(0, props.num_players - props.prompts.length);
437 const max_votes = props.prompts.reduce(
438 (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
440 if (max_votes < quorum) {
441 let text = `Before we play, we should collect a bit
442 more information about what category would
443 be interesting for this group. So, either
444 type a new category option above, or else`;
445 if (props.prompts.length) {
446 if (props.prompts.length > 1)
447 text += " vote on some of the categories below.";
449 text += " vote on the category below.";
451 text += " wait for others to submit, and then vote on them below.";
455 <div className="before-we-play">
463 const candidates = props.prompts.filter(p => p.votes.length >= max_votes);
464 const index = Math.floor(Math.random() * candidates.length);
465 const winner = candidates[index];
468 <div className="lets-play">
471 That should be enough voting. If you're not waiting for any
472 other players to join, then let's start.
475 className="lets-play"
476 onClick={() => fetch_post_json(`start/${winner.id}`) }
484 class Ambiguities extends React.PureComponent {
489 function canonize(word) {
490 return word.replace(/((a|an|the) )?(.*?)s?$/i, '$3');
493 const word_sets = [];
495 for (let word of props.words) {
496 const word_canon = canonize(word);
497 let found_match = false;
498 for (let set of word_sets) {
499 const set_canon = canonize(set.values().next().value);
500 if (word_canon === set_canon) {
507 const set = new Set();
514 word_sets: word_sets,
519 this.submitted = false;
520 this.judging_sent_recently = false;
523 async handle_submit() {
525 /* Don't submit a second time. */
529 const response = await fetch_post_json(
530 `judged/${this.props.prompt.id}`,{
531 word_groups: this.state.word_sets.map(
533 words: Array.from(set),
534 kudos: this.state.starred === set ? true : false
539 if (response.status === 200) {
540 const result = await response.json();
541 if (! result.valid) {
542 add_message("danger", result.message);
546 add_message("danger", "An error occurred submitting the results of your judging");
550 this.submitted = true;
555 /* Let the server know we are doing some judging, (but rate limit
556 * this so we don't send a "judging" notification more frquently
559 if (! this.judging_sent_recently) {
560 fetch_post_json(`judging/${this.props.prompt.id}`);
561 this.judging_sent_recently = true;
562 setTimeout(() => { this.judging_sent_recently = false; }, 1000);
565 if (this.state.selected == word) {
566 /* Second click on same word removes the word from the group. */
567 const idx = this.state.word_sets.findIndex(s => s.has(word));
568 const set = this.state.word_sets[idx];
569 if (set.size === 1) {
570 /* When the word is already alone, there's nothing to do but
571 * to un-select it. */
578 const new_set = new Set([...set].filter(w => w !== word));
581 word_sets: [...this.state.word_sets.slice(0, idx),
584 ...this.state.word_sets.slice(idx+1)]
586 } else if (this.state.selected) {
587 /* Click of a second word groups the two together. */
588 const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
589 const idx2 = this.state.word_sets.findIndex(s => s.has(word));
590 const set1 = this.state.word_sets[idx1];
591 const set2 = this.state.word_sets[idx2];
592 const new_set = new Set([...set2, ...set1]);
596 word_sets: [...this.state.word_sets.slice(0, idx1),
597 ...this.state.word_sets.slice(idx1 + 1, idx2),
599 ...this.state.word_sets.slice(idx2 + 1)]
604 word_sets: [...this.state.word_sets.slice(0, idx2),
606 ...this.state.word_sets.slice(idx2 + 1, idx1),
607 ...this.state.word_sets.slice(idx1 + 1)]
611 /* First click of a word selects it. */
619 let move_on_button = null;
621 if (this.props.idle) {
624 className="vote-button"
625 onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
627 Move On Without Their Input
628 <div className="vote-choices">
629 {[...this.props.votes].map(v => {
633 className="vote-choice"
644 let still_waiting = null;
645 const judging_players = Object.keys(this.props.players_judging);
646 if (judging_players.length) {
650 Still waiting for the following player
651 {judging_players.length > 1 ? 's' : '' }
655 {judging_players.map(player => {
662 {this.props.players_judging[player].active ?
666 <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
676 if (this.props.players_judged.has(this.props.player.name)) {
678 <div className="please-wait">
679 <h2>Submission received</h2>
681 The following players have completed judging:{' '}
682 {[...this.props.players_judged].join(', ')}
691 const btn_class = "ambiguity-button";
692 const btn_selected_class = btn_class + " selected";
695 <div className="ambiguities">
696 <h2>Judging Answers</h2>
698 Click/tap on each pair of answers that should be scored as equivalent,
699 (or click a word twice to split it out from a group). Remember,
700 what goes around comes around, so it's best to be generous when
704 Also, for an especially fun or witty answer, you can give kudos
705 by clicking the star on the right. You may only do this for one
708 <h2>{this.props.prompt.prompt}</h2>
709 {this.state.word_sets.map(set => {
712 className="ambiguity-group"
713 key={Array.from(set)[0]}
715 {Array.from(set).map(word => {
718 className={this.state.selected === word ?
719 btn_selected_class : btn_class }
721 onClick={() => this.handle_click(word)}
728 className={this.state.starred === set ?
729 "star-button selected" : "star-button"
731 onClick={(event) => {
732 event.stopPropagation();
738 {this.state.starred === set ?
746 Click here when done judging:<br/>
748 onClick={() => this.handle_submit()}
758 class ActivePrompt extends React.PureComponent {
762 const items = props.prompt.items;
764 this.submitted = false;
766 this.answers = [...Array(items)].map(() => React.createRef());
767 this.answering_sent_recently = false;
769 this.handle_submit = this.handle_submit.bind(this);
770 this.handle_change = this.handle_change.bind(this);
773 handle_change(event) {
774 /* We don't care (or even look) at what the player is typing at
775 * this point. We simply want to be informed that the player _is_
776 * typing so that we can tell the server (which will tell other
777 * players) that there is activity here.
780 /* Rate limit so that we don't send an "answering" notification
781 * more frequently than necessary.
783 if (! this.answering_sent_recently) {
784 fetch_post_json(`answering/${this.props.prompt.id}`);
785 this.answering_sent_recently = true;
786 setTimeout(() => { this.answering_sent_recently = false; }, 1000);
790 async handle_submit(event) {
791 const form = event.currentTarget;
793 /* Prevent the default page-changing form-submission behavior. */
794 event.preventDefault();
796 /* And don't submit a second time. */
800 const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
801 answers: this.answers.map(r => r.current.value)
803 if (response.status === 200) {
804 const result = await response.json();
805 if (! result.valid) {
806 add_message("danger", result.message);
810 add_message("danger", "An error occurred submitting your answers");
814 /* Everything worked. Server is happy with our answers. */
816 this.submitted = true;
821 let still_waiting = null;
822 const answering_players = Object.keys(this.props.players_answering);;
823 if (answering_players.length) {
827 Still waiting for the following player
828 {answering_players.length > 1 ? 's' : ''}
832 {answering_players.map(player => {
839 {this.props.players_answering[player].active ?
843 <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
853 let move_on_button = null;
854 if (this.props.idle) {
857 className="vote-button"
858 onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
860 {answering_players.length ?
861 "Move On Without Their Answers" :
862 "Move On Without Anyone Else"}
863 <div className="vote-choices">
864 {[...this.props.votes].map(v => {
868 className="vote-choice"
879 if (this.props.players_answered.has(this.props.player.name)) {
881 <div className="please-wait">
882 <h2>Submission received</h2>
884 The following players have submitted their answers:{' '}
885 {[...this.props.players_answered].join(', ')}
895 <div className="active-prompt">
896 <h2>The Game of Empathy</h2>
898 Remember, you're trying to match your answers with
899 what the other players submit.
900 Give {this.props.prompt.items} answer
901 {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
903 <h2>{this.props.prompt.prompt}</h2>
904 <form onSubmit={this.handle_submit}>
905 {[...Array(this.props.prompt.items)].map((whocares,i) => {
909 className="form-field large">
915 onChange={this.handle_change}
916 ref={this.answers[i]}
924 className="form-field large">
925 <button type="submit">
936 class Game extends React.PureComponent {
945 players_answered: new Set(),
946 players_answering: {},
947 answering_idle: false,
948 end_answers_votes: new Set(),
950 players_judged: new Set(),
953 end_judging_votes: new Set(),
955 new_game_votes: new Set(),
960 set_game_info(info) {
966 set_player_info(info) {
972 set_other_player_info(info) {
973 const other_players_copy = [...this.state.other_players];
974 const idx = other_players_copy.findIndex(o => o.id === info.id);
976 other_players_copy[idx] = info;
978 other_players_copy.push(info);
981 other_players: other_players_copy
985 remove_player(info) {
987 other_players: this.state.other_players.filter(o => o.id !== info.id)
995 players_answered: new Set(),
996 players_answering: {},
997 answering_idle: false,
998 end_answers_votes: new Set(),
1000 players_judged: new Set(),
1001 players_judging: {},
1002 judging_idle: false,
1003 end_judging_votes: new Set(),
1005 new_game_votes: new Set(),
1010 set_prompts(prompts) {
1016 add_or_update_prompt(prompt) {
1017 const prompts_copy = [...this.state.prompts];
1018 const idx = prompts_copy.findIndex(p => p.id === prompt.id);
1020 prompts_copy[idx] = prompt;
1022 prompts_copy.push(prompt);
1025 prompts: prompts_copy
1029 set_active_prompt(prompt) {
1031 active_prompt: prompt
1035 set_players_answered(players) {
1037 players_answered: new Set(players)
1041 set_player_answered(player) {
1042 const new_players_answering = {...this.state.players_answering};
1043 delete new_players_answering[player];
1046 players_answered: new Set([...this.state.players_answered, player]),
1047 players_answering: new_players_answering
1051 set_players_answering(players) {
1052 const players_answering = {};
1053 for (let player of players) {
1054 players_answering[player] = {active: false};
1057 players_answering: players_answering
1061 set_player_answering(player) {
1062 /* Set the player as actively answering now. */
1064 players_answering: {
1065 ...this.state.players_answering,
1066 [player]: {active: true}
1069 /* And arrange to have them marked idle very shortly.
1071 * Note: This timeout is intentionally very, very short. We only
1072 * need it long enough that the browser has latched onto the state
1073 * change to "active" above. We actually use a CSS transition
1074 * delay to control the user-perceptible length of time after
1075 * which an active player appears inactive.
1079 players_answering: {
1080 ...this.state.players_answering,
1081 [player]: {active: false}
1087 set_answering_idle(value) {
1089 answering_idle: value
1093 set_end_answers(players) {
1095 end_answers_votes: new Set(players)
1099 set_player_vote_end_answers(player) {
1101 end_answers_votes: new Set([...this.state.end_answers_votes, player])
1105 set_player_unvote_end_answers(player) {
1107 end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
1111 set_ambiguities(ambiguities) {
1113 ambiguities: ambiguities
1117 set_players_judged(players) {
1119 players_judged: new Set(players)
1123 set_player_judged(player) {
1124 const new_players_judging = {...this.state.players_judging};
1125 delete new_players_judging[player];
1128 players_judged: new Set([...this.state.players_judged, player]),
1129 players_judging: new_players_judging
1133 set_players_judging(players) {
1134 const players_judging = {};
1135 for (let player of players) {
1136 players_judging[player] = {active: false};
1139 players_judging: players_judging
1143 set_player_judging(player) {
1144 /* Set the player as actively judging now. */
1147 ...this.state.players_judging,
1148 [player]: {active: true}
1151 /* And arrange to have them marked idle very shortly.
1153 * Note: This timeout is intentionally very, very short. We only
1154 * need it long enough that the browser has latched onto the state
1155 * change to "active" above. We actually use a CSS transition
1156 * delay to control the user-perceptible length of time after
1157 * which an active player appears inactive.
1162 ...this.state.players_judging,
1163 [player]: {active: false}
1170 set_judging_idle(value) {
1176 set_end_judging(players) {
1178 end_judging_votes: new Set(players)
1182 set_player_vote_end_judging(player) {
1184 end_judging_votes: new Set([...this.state.end_judging_votes, player])
1188 set_player_unvote_end_judging(player) {
1190 end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
1194 set_scores(scores) {
1200 set_new_game_votes(players) {
1202 new_game_votes: new Set(players)
1206 set_player_vote_new_game(player) {
1208 new_game_votes: new Set([...this.state.new_game_votes, player])
1212 set_player_unvote_new_game(player) {
1214 new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
1225 const state = this.state;
1226 const players_total = 1 + state.other_players.length;
1230 let perfect_score = 0;
1232 i < state.active_prompt.items &&
1233 i < state.scores.words.length;
1236 perfect_score += state.scores.words[i].players.length;
1240 <div className="scores">
1241 <h2>{state.active_prompt.prompt}</h2>
1244 {state.scores.scores.map(score => {
1246 if (score.score === perfect_score) {
1247 perfect = <span className="achievement">Perfect!</span>;
1249 let quirkster = null;
1250 if (score.score === state.active_prompt.items) {
1251 quirkster = <span className="achievement">Quirkster!</span>;
1253 let kudos_slam = null;
1254 if (score.kudos > 0 && score.kudos >= players_total - 1) {
1255 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1258 <li key={score.players[0]}>
1259 {score.players.join("/")}: {score.score}
1260 {score.kudos ? `, ${'★'.repeat(score.kudos)}` : ""}
1261 {' '}{perfect} {quirkster} {kudos_slam}
1266 <h2>Words submitted</h2>
1268 {state.scores.words.map(word => {
1269 let great_minds = null;
1270 if (word.kudos.length && word.players.length > 1) {
1271 great_minds = <span className="achievement">Great Minds!</span>;
1273 let kudos_slam = null;
1274 if (word.kudos.length > 0 && word.kudos.length >= players_total - 1) {
1275 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1278 <li key={word.word}>
1279 {word.word} ({word.players.length}
1280 {word.kudos.length ? `, ${'★'.repeat(word.kudos.length)}` : ""}
1281 ): {word.players.join(', ')}
1282 {' '}{great_minds}{kudos_slam}
1288 className="vote-button"
1289 onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
1292 <div className="vote-choices">
1293 {[...state.new_game_votes].map(v => {
1297 className="vote-choice"
1309 if (state.ambiguities){
1311 prompt={state.active_prompt}
1312 words={state.ambiguities}
1313 player={state.player_info}
1314 players_judged={state.players_judged}
1315 players_judging={state.players_judging}
1316 idle={state.judging_idle}
1317 votes={state.end_judging_votes}
1321 if (state.active_prompt) {
1322 return <ActivePrompt
1323 prompt={state.active_prompt}
1324 player={state.player_info}
1325 players_answered={state.players_answered}
1326 players_answering={state.players_answering}
1327 idle={state.answering_idle}
1328 votes={state.end_answers_votes}
1338 id={state.game_info.id}
1339 url={state.game_info.url}
1344 player={state.player_info}
1345 other_players={state.other_players}
1347 <p key="spacer"></p>,
1349 key="category-request"
1353 num_players={1+state.other_players.length}
1354 prompts={state.prompts}
1358 prompts={state.prompts}
1359 player={state.player_info}
1365 ReactDOM.render(<Game
1366 ref={(me) => window.game = me}
1367 />, document.getElementById("empathy"));