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();
733 if (this.state.starred === set) {
744 {this.state.starred === set ?
752 Click here when done judging:<br/>
754 onClick={() => this.handle_submit()}
764 class ActivePrompt extends React.PureComponent {
768 const items = props.prompt.items;
770 this.submitted = false;
772 this.answers = [...Array(items)].map(() => React.createRef());
773 this.answering_sent_recently = false;
775 this.handle_submit = this.handle_submit.bind(this);
776 this.handle_change = this.handle_change.bind(this);
779 handle_change(event) {
780 /* We don't care (or even look) at what the player is typing at
781 * this point. We simply want to be informed that the player _is_
782 * typing so that we can tell the server (which will tell other
783 * players) that there is activity here.
786 /* Rate limit so that we don't send an "answering" notification
787 * more frequently than necessary.
789 if (! this.answering_sent_recently) {
790 fetch_post_json(`answering/${this.props.prompt.id}`);
791 this.answering_sent_recently = true;
792 setTimeout(() => { this.answering_sent_recently = false; }, 1000);
796 async handle_submit(event) {
797 const form = event.currentTarget;
799 /* Prevent the default page-changing form-submission behavior. */
800 event.preventDefault();
802 /* And don't submit a second time. */
806 const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
807 answers: this.answers.map(r => r.current.value)
809 if (response.status === 200) {
810 const result = await response.json();
811 if (! result.valid) {
812 add_message("danger", result.message);
816 add_message("danger", "An error occurred submitting your answers");
820 /* Everything worked. Server is happy with our answers. */
822 this.submitted = true;
827 let still_waiting = null;
828 const answering_players = Object.keys(this.props.players_answering);;
829 if (answering_players.length) {
833 Still waiting for the following player
834 {answering_players.length > 1 ? 's' : ''}
838 {answering_players.map(player => {
845 {this.props.players_answering[player].active ?
849 <span>{'.'}</span><span>{'.'}</span><span>{'.'}</span>
859 let move_on_button = null;
860 if (this.props.idle) {
863 className="vote-button"
864 onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
866 {answering_players.length ?
867 "Move On Without Their Answers" :
868 "Move On Without Anyone Else"}
869 <div className="vote-choices">
870 {[...this.props.votes].map(v => {
874 className="vote-choice"
885 if (this.props.players_answered.has(this.props.player.name)) {
887 <div className="please-wait">
888 <h2>Submission received</h2>
890 The following players have submitted their answers:{' '}
891 {[...this.props.players_answered].join(', ')}
901 <div className="active-prompt">
902 <h2>The Game of Empathy</h2>
904 Remember, you're trying to match your answers with
905 what the other players submit.
906 Give {this.props.prompt.items} answer
907 {this.props.prompt.items > 1 ? 's' : ''} for the following prompt:
909 <h2>{this.props.prompt.prompt}</h2>
910 <form onSubmit={this.handle_submit}>
911 {[...Array(this.props.prompt.items)].map((whocares,i) => {
915 className="form-field large">
921 onChange={this.handle_change}
922 ref={this.answers[i]}
930 className="form-field large">
931 <button type="submit">
942 class Game extends React.PureComponent {
951 players_answered: new Set(),
952 players_answering: {},
953 answering_idle: false,
954 end_answers_votes: new Set(),
956 players_judged: new Set(),
959 end_judging_votes: new Set(),
961 new_game_votes: new Set(),
966 set_game_info(info) {
972 set_player_info(info) {
978 set_other_player_info(info) {
979 const other_players_copy = [...this.state.other_players];
980 const idx = other_players_copy.findIndex(o => o.id === info.id);
982 other_players_copy[idx] = info;
984 other_players_copy.push(info);
987 other_players: other_players_copy
991 remove_player(info) {
993 other_players: this.state.other_players.filter(o => o.id !== info.id)
1000 active_prompt: null,
1001 players_answered: new Set(),
1002 players_answering: {},
1003 answering_idle: false,
1004 end_answers_votes: new Set(),
1006 players_judged: new Set(),
1007 players_judging: {},
1008 judging_idle: false,
1009 end_judging_votes: new Set(),
1011 new_game_votes: new Set(),
1016 set_prompts(prompts) {
1022 add_or_update_prompt(prompt) {
1023 const prompts_copy = [...this.state.prompts];
1024 const idx = prompts_copy.findIndex(p => p.id === prompt.id);
1026 prompts_copy[idx] = prompt;
1028 prompts_copy.push(prompt);
1031 prompts: prompts_copy
1035 set_active_prompt(prompt) {
1037 active_prompt: prompt
1041 set_players_answered(players) {
1043 players_answered: new Set(players)
1047 set_player_answered(player) {
1048 const new_players_answering = {...this.state.players_answering};
1049 delete new_players_answering[player];
1052 players_answered: new Set([...this.state.players_answered, player]),
1053 players_answering: new_players_answering
1057 set_players_answering(players) {
1058 const players_answering = {};
1059 for (let player of players) {
1060 players_answering[player] = {active: false};
1063 players_answering: players_answering
1067 set_player_answering(player) {
1068 /* Set the player as actively answering now. */
1070 players_answering: {
1071 ...this.state.players_answering,
1072 [player]: {active: true}
1075 /* And arrange to have them marked idle very shortly.
1077 * Note: This timeout is intentionally very, very short. We only
1078 * need it long enough that the browser has latched onto the state
1079 * change to "active" above. We actually use a CSS transition
1080 * delay to control the user-perceptible length of time after
1081 * which an active player appears inactive.
1085 players_answering: {
1086 ...this.state.players_answering,
1087 [player]: {active: false}
1093 set_answering_idle(value) {
1095 answering_idle: value
1099 set_end_answers(players) {
1101 end_answers_votes: new Set(players)
1105 set_player_vote_end_answers(player) {
1107 end_answers_votes: new Set([...this.state.end_answers_votes, player])
1111 set_player_unvote_end_answers(player) {
1113 end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
1117 set_ambiguities(ambiguities) {
1119 ambiguities: ambiguities
1123 set_players_judged(players) {
1125 players_judged: new Set(players)
1129 set_player_judged(player) {
1130 const new_players_judging = {...this.state.players_judging};
1131 delete new_players_judging[player];
1134 players_judged: new Set([...this.state.players_judged, player]),
1135 players_judging: new_players_judging
1139 set_players_judging(players) {
1140 const players_judging = {};
1141 for (let player of players) {
1142 players_judging[player] = {active: false};
1145 players_judging: players_judging
1149 set_player_judging(player) {
1150 /* Set the player as actively judging now. */
1153 ...this.state.players_judging,
1154 [player]: {active: true}
1157 /* And arrange to have them marked idle very shortly.
1159 * Note: This timeout is intentionally very, very short. We only
1160 * need it long enough that the browser has latched onto the state
1161 * change to "active" above. We actually use a CSS transition
1162 * delay to control the user-perceptible length of time after
1163 * which an active player appears inactive.
1168 ...this.state.players_judging,
1169 [player]: {active: false}
1176 set_judging_idle(value) {
1182 set_end_judging(players) {
1184 end_judging_votes: new Set(players)
1188 set_player_vote_end_judging(player) {
1190 end_judging_votes: new Set([...this.state.end_judging_votes, player])
1194 set_player_unvote_end_judging(player) {
1196 end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
1200 set_scores(scores) {
1206 set_new_game_votes(players) {
1208 new_game_votes: new Set(players)
1212 set_player_vote_new_game(player) {
1214 new_game_votes: new Set([...this.state.new_game_votes, player])
1218 set_player_unvote_new_game(player) {
1220 new_game_votes: new Set([...this.state.new_game_votes].filter(p => p !== player))
1231 const state = this.state;
1232 const players_total = 1 + state.other_players.length;
1236 let perfect_score = 0;
1238 i < state.active_prompt.items &&
1239 i < state.scores.words.length;
1242 perfect_score += state.scores.words[i].players.length;
1246 <div className="scores">
1247 <h2>{state.active_prompt.prompt}</h2>
1250 {state.scores.scores.map(score => {
1252 if (score.score === perfect_score) {
1253 perfect = <span className="achievement">Perfect!</span>;
1255 let quirkster = null;
1256 if (score.score === state.active_prompt.items) {
1257 quirkster = <span className="achievement">Quirkster!</span>;
1259 let kudos_slam = null;
1260 if (score.kudos > 0 && score.kudos >= players_total - 1) {
1261 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1264 <li key={score.players[0]}>
1265 {score.players.join("/")}: {score.score}
1266 {score.kudos ? `, ${'★'.repeat(score.kudos)}` : ""}
1267 {' '}{perfect} {quirkster} {kudos_slam}
1272 <h2>Words submitted</h2>
1274 {state.scores.words.map(word => {
1275 let great_minds = null;
1276 if (word.kudos.length && word.players.length > 1) {
1277 great_minds = <span className="achievement">Great Minds!</span>;
1279 let kudos_slam = null;
1280 if (word.kudos.length > 0 && word.kudos.length >= players_total - 1) {
1281 kudos_slam = <span className="achievement">Kudos Slam!</span>;
1284 <li key={word.word}>
1285 {word.word} ({word.players.length}
1286 {word.kudos.length ? `, ${'★'.repeat(word.kudos.length)}` : ""}
1287 ): {word.players.join(', ')}
1288 {' '}{great_minds}{kudos_slam}
1294 className="vote-button"
1295 onClick={() => fetch_post_json(`new-game/${state.active_prompt.id}`) }
1298 <div className="vote-choices">
1299 {[...state.new_game_votes].map(v => {
1303 className="vote-choice"
1315 if (state.ambiguities){
1317 prompt={state.active_prompt}
1318 words={state.ambiguities}
1319 player={state.player_info}
1320 players_judged={state.players_judged}
1321 players_judging={state.players_judging}
1322 idle={state.judging_idle}
1323 votes={state.end_judging_votes}
1327 if (state.active_prompt) {
1328 return <ActivePrompt
1329 prompt={state.active_prompt}
1330 player={state.player_info}
1331 players_answered={state.players_answered}
1332 players_answering={state.players_answering}
1333 idle={state.answering_idle}
1334 votes={state.end_answers_votes}
1344 id={state.game_info.id}
1345 url={state.game_info.url}
1350 player={state.player_info}
1351 other_players={state.other_players}
1353 <p key="spacer"></p>,
1355 key="category-request"
1359 num_players={1+state.other_players.length}
1360 prompts={state.prompts}
1364 prompts={state.prompts}
1365 player={state.player_info}
1371 ReactDOM.render(<Game
1372 ref={(me) => window.game = me}
1373 />, document.getElementById("empathy"));