]> git.cworth.org Git - lmno.games/blob - empathy/empathy.jsx
Wait for judging phase to be idle before displaying "Move On" button
[lmno.games] / empathy / empathy.jsx
1 const MAX_PROMPT_ITEMS = 20;
2
3 function undisplay(element) {
4   element.style.display="none";
5 }
6
7 function add_message(severity, message) {
8   message = `<div class="message ${severity}" onclick="undisplay(this)">
9 <span class="hide-button" onclick="undisplay(this.parentElement)">&times;</span>
10 ${message}
11 </div>`;
12   const message_area = document.getElementById('message-area');
13   message_area.insertAdjacentHTML('beforeend', message);
14 }
15
16 /*********************************************************
17  * Handling server-sent event stream                     *
18  *********************************************************/
19
20 const events = new EventSource("events");
21
22 events.onerror = function(event) {
23   if (event.target.readyState === EventSource.CLOSED) {
24     setTimeout(() => {
25       add_message("danger", "Connection to server lost.");
26     }, 1000);
27   }
28 };
29
30 events.addEventListener("game-info", event => {
31   const info = JSON.parse(event.data);
32
33   window.game.set_game_info(info);
34 });
35
36 events.addEventListener("player-info", event => {
37   const info = JSON.parse(event.data);
38
39   window.game.set_player_info(info);
40 });
41
42 events.addEventListener("player-enter", event => {
43   const info = JSON.parse(event.data);
44
45   window.game.set_other_player_info(info);
46 });
47
48 events.addEventListener("player-update", event => {
49   const info = JSON.parse(event.data);
50
51   if (info.id === window.game.state.player_info.id)
52     window.game.set_player_info(info);
53   else
54     window.game.set_other_player_info(info);
55 });
56
57 events.addEventListener("game-state", event => {
58   const state = JSON.parse(event.data);
59
60   window.game.reset_game_state();
61
62   window.game.set_prompts(state.prompts);
63
64   window.game.set_active_prompt(state.active_prompt);
65
66   window.game.set_players_answered(state.players_answered);
67
68   window.game.set_players_answering(state.players_answering);
69
70   window.game.set_answering_idle(state.answering_idle);
71
72   window.game.set_end_answers(state.end_answers);
73
74   window.game.set_ambiguities(state.ambiguities);
75
76   window.game.set_players_judged(state.players_judged);
77
78   window.game.set_players_judging(state.players_judging);
79
80   window.game.set_judging_idle(state.judging_idle);
81
82   window.game.set_end_judging(state.end_judging);
83
84   window.game.set_scores(state.scores);
85 });
86
87 events.addEventListener("prompt", event => {
88   const prompt = JSON.parse(event.data);
89
90   window.game.add_or_update_prompt(prompt);
91 });
92
93 events.addEventListener("start", event => {
94   const prompt = JSON.parse(event.data);
95
96   window.game.set_active_prompt(prompt);
97 });
98
99 events.addEventListener("player-answered", event => {
100   const player = JSON.parse(event.data);
101
102   window.game.set_player_answered(player);
103 });
104
105 events.addEventListener("player-answering", event => {
106   const player = JSON.parse(event.data);
107
108   window.game.set_player_answering(player);
109 });
110
111 events.addEventListener("answering-idle", event => {
112   const value = JSON.parse(event.data);
113
114   window.game.set_answering_idle(value);
115 });
116
117 events.addEventListener("vote-end-answers", event => {
118   const player = JSON.parse(event.data);
119
120   window.game.set_player_vote_end_answers(player);
121 });
122
123 events.addEventListener("unvote-end-answers", event => {
124   const player = JSON.parse(event.data);
125
126   window.game.set_player_unvote_end_answers(player);
127 });
128
129 events.addEventListener("ambiguities", event => {
130   const ambiguities = JSON.parse(event.data);
131
132   window.game.set_ambiguities(ambiguities);
133 });
134
135 events.addEventListener("player-judged", event => {
136   const player = JSON.parse(event.data);
137
138   window.game.set_player_judged(player);
139 });
140
141 events.addEventListener("player-judging", event => {
142   const player = JSON.parse(event.data);
143
144   window.game.set_player_judging(player);
145 });
146
147 events.addEventListener("judging-idle", event => {
148   const value = JSON.parse(event.data);
149
150   window.game.set_judging_idle(value);
151 });
152
153 events.addEventListener("vote-end-judging", event => {
154   const player = JSON.parse(event.data);
155
156   window.game.set_player_vote_end_judging(player);
157 });
158
159 events.addEventListener("unvote-end-judging", event => {
160   const player = JSON.parse(event.data);
161
162   window.game.set_player_unvote_end_judging(player);
163 });
164
165 events.addEventListener("scores", event => {
166   const scores = JSON.parse(event.data);
167
168   window.game.set_scores(scores);
169 });
170
171 /*********************************************************
172  * Game and supporting classes                           *
173  *********************************************************/
174
175 function copy_to_clipboard(id)
176 {
177   const tmp = document.createElement("input");
178   tmp.setAttribute("value", document.getElementById(id).innerHTML);
179   document.body.appendChild(tmp);
180   tmp.select();
181   document.execCommand("copy");
182   document.body.removeChild(tmp);
183 }
184
185 const GameInfo = React.memo(props => {
186   if (! props.id)
187     return null;
188
189   return (
190     <div className="game-info">
191       <span className="game-id">{props.id}</span>
192       {" "}
193       Share this link to invite friends:{" "}
194       <span id="game-share-url">{props.url}</span>
195       {" "}
196       <button
197         className="inline"
198         onClick={() => copy_to_clipboard('game-share-url')}
199       >Copy Link</button>
200     </div>
201   );
202 });
203
204 const PlayerInfo = React.memo(props => {
205   if (! props.player.id)
206     return null;
207
208   return (
209     <div className="player-info">
210       <span className="players-header">Players: </span>
211       {props.player.name}
212       {props.player.score > 0 ? ` (${props.player.score})` : ""}
213       {props.other_players.map(other => (
214         <span key={other.id}>
215           {", "}
216           {other.name}
217           {other.score > 0 ? ` (${other.score})` : ""}
218         </span>
219       ))}
220     </div>
221   );
222 });
223
224 function fetch_method_json(method, api = '', data = {}) {
225   const response = fetch(api, {
226     method: method,
227     headers: {
228       'Content-Type': 'application/json'
229     },
230     body: JSON.stringify(data)
231   });
232   return response;
233 }
234
235 function fetch_post_json(api = '', data = {}) {
236   return fetch_method_json('POST', api, data);
237 }
238
239 async function fetch_put_json(api = '', data = {}) {
240   return fetch_method_json('PUT', api, data);
241 }
242
243 class CategoryRequest extends React.PureComponent {
244   constructor(props) {
245     super(props);
246     this.category = React.createRef();
247
248     this.handle_change = this.handle_change.bind(this);
249     this.handle_submit = this.handle_submit.bind(this);
250   }
251
252   handle_change(event) {
253     const category_input = this.category.current;
254     const category = category_input.value;
255
256     const match = category.match(/[0-9]+/);
257     if (match) {
258       const num_items = parseInt(match[0], 10);
259       if (num_items <= MAX_PROMPT_ITEMS)
260         category_input.setCustomValidity("");
261     }
262   }
263
264   async handle_submit(event) {
265     const form = event.currentTarget;
266     const category_input = this.category.current;
267     const category = category_input.value;
268
269     /* Prevent the default page-changing form-submission behavior. */
270     event.preventDefault();
271
272     const match = category.match(/[0-9]+/);
273     if (match === null) {
274       category_input.setCustomValidity("Category must include a number");
275       form.reportValidity();
276       return;
277     }
278
279     const num_items = parseInt(match[0], 10);
280
281     if (num_items > MAX_PROMPT_ITEMS) {
282       category_input.setCustomValidity(`Maximum number of items is ${MAX_PROMPT_ITEMS}`);
283       form.reportValidity();
284       return;
285     }
286
287     const response = await fetch_post_json("prompts", {
288       items: num_items,
289       prompt: category
290     });
291
292     if (response.status === 200) {
293       const result = await response.json();
294       if (! result.valid) {
295         add_message("danger", result.message);
296         return;
297       }
298     } else {
299       add_message("danger", "An error occurred submitting your category");
300     }
301
302     form.reset();
303   }
304
305   render() {
306     return (
307       <div className="category-request">
308         <h2>Submit a Category</h2>
309         <p>
310           Suggest a category to play. Don't forget to include the
311           number of items for each person to submit.
312         </p>
313
314         <form onSubmit={this.handle_submit} >
315           <div className="form-field large">
316             <input
317               type="text"
318               id="category"
319               placeholder="6 things at the beach"
320               required
321               autoComplete="off"
322               onChange={this.handle_change}
323               ref={this.category}
324             />
325           </div>
326
327           <div className="form-field large">
328             <button type="submit">
329               Send
330             </button>
331           </div>
332
333         </form>
334       </div>
335     );
336   }
337 }
338
339 const PromptOptions = React.memo(props => {
340
341   if (props.prompts.length === 0)
342     return null;
343
344   return (
345     <div className="prompt-options">
346       <h2>Vote on Categories</h2>
347       <p>
348         Select any categories below that you'd like to play.
349         You can choose as many as you'd like.
350       </p>
351       {props.prompts.map(p => {
352         return (
353           <button
354             className="vote-button"
355             key={p.id}
356             onClick={() => fetch_post_json(`vote/${p.id}`) }
357           >
358             {p.prompt}
359             <div className="vote-choices">
360               {p.votes.map(v => {
361                 return (
362                   <div
363                     key={v}
364                     className="vote-choice"
365                   >
366                     {v}
367                   </div>
368                 );
369               })}
370             </div>
371           </button>
372         );
373       })}
374     </div>
375   );
376 });
377
378 const LetsPlay = React.memo(props => {
379
380   const quorum = Math.round((props.num_players + 1) / 2);
381   const max_votes = props.prompts.reduce(
382     (max_so_far, v) => Math.max(max_so_far, v.votes.length), 0);
383
384   if (max_votes < quorum)
385     return null;
386
387   const candidates = props.prompts.filter(p => p.votes.length >= quorum);
388   const index = Math.floor(Math.random() * candidates.length);
389   const winner = candidates[index];
390
391   return (
392     <div className="lets-play">
393       <h2>Let's Play</h2>
394       <p>
395         That should be enough voting. If you're not waiting for any
396         other players to join, then let's start.
397       </p>
398       <button
399         className="lets-play"
400         onClick={() => fetch_post_json(`start/${winner.id}`) }
401       >
402         Start Game
403       </button>
404     </div>
405   );
406 });
407
408 class Ambiguities extends React.PureComponent {
409
410   constructor(props) {
411     super(props);
412
413     const word_sets = props.words.map(word => {
414       const set = new Set();
415       set.add(word);
416       return set;
417     });
418
419     this.state = {
420       word_sets: word_sets,
421       selected: null
422     };
423
424     this.submitted = false;
425     this.judging_sent_recently = false;
426   }
427
428   async handle_submit() {
429
430     /* Don't submit a second time. */
431     if (this.submitted)
432       return;
433
434     const response = await fetch_post_json(
435       `judged/${this.props.prompt.id}`,{
436         word_groups: this.state.word_sets.map(set => Array.from(set))
437       }
438     );
439
440     if (response.status === 200) {
441       const result = await response.json();
442       if (! result.valid) {
443         add_message("danger", result.message);
444         return;
445       }
446     } else {
447       add_message("danger", "An error occurred submitting the results of your judging");
448       return;
449     }
450
451     this.submitted = true;
452   }
453
454   handle_click(word) {
455
456     /* Let the server know we are doing some judging, (but rate limit
457      * this so we don't send a "judging" notification more frquently
458      * than necessary.
459      */
460     if (! this.judging_sent_recently) {
461       fetch_post_json(`judging/${this.props.prompt.id}`);
462       this.judging_sent_recently = true;
463       setTimeout(() => { this.judging_sent_recently = false; }, 1000);
464     }
465
466     if (this.state.selected == word) {
467       /* Second click on same word removes the word from the group. */
468       const idx = this.state.word_sets.findIndex(s => s.has(word));
469       const set = this.state.word_sets[idx];
470       if (set.size === 1) {
471         /* When the word is already alone, there's nothing to do but
472          * to un-select it. */
473         this.setState({
474           selected: null
475         });
476         return;
477       }
478
479       const new_set = new Set([...set].filter(w => w !== word));
480       this.setState({
481         selected: null,
482         word_sets: [...this.state.word_sets.slice(0, idx),
483                     new_set,
484                     new Set().add(word),
485                     ...this.state.word_sets.slice(idx+1)]
486       });
487     } else if (this.state.selected) {
488       /* Click of a second word groups the two together. */
489       const idx1 = this.state.word_sets.findIndex(s => s.has(this.state.selected));
490       const idx2 = this.state.word_sets.findIndex(s => s.has(word));
491       const set1 = this.state.word_sets[idx1];
492       const set2 = this.state.word_sets[idx2];
493       const new_set = new Set([...set2, ...set1]);
494       if (idx1 < idx2) {
495         this.setState({
496           selected: null,
497           word_sets: [...this.state.word_sets.slice(0, idx1),
498                       ...this.state.word_sets.slice(idx1 + 1, idx2),
499                       new_set,
500                       ...this.state.word_sets.slice(idx2 + 1)]
501         });
502       } else {
503         this.setState({
504           selected: null,
505           word_sets: [...this.state.word_sets.slice(0, idx2),
506                       new_set,
507                       ...this.state.word_sets.slice(idx2 + 1, idx1),
508                       ...this.state.word_sets.slice(idx1 + 1)]
509         });
510       }
511     } else {
512       /* First click of a word selects it. */
513       this.setState({
514         selected: word
515       });
516     }
517   }
518
519   render() {
520     let move_on_button = null;
521
522     if (this.props.idle) {
523       move_on_button = (
524         <button
525           className="vote-button"
526           onClick={() => fetch_post_json(`end-judging/${this.props.prompt.id}`) }
527         >
528           Move On
529           <div className="vote-choices">
530             {[...this.props.votes].map(v => {
531               return (
532                 <div
533                   key={v}
534                   className="vote-choice"
535                 >
536                   {v}
537                   </div>
538               );
539             })}
540           </div>
541         </button>
542       );
543     }
544
545     if (this.props.players_judged.has(this.props.player.name)) {
546       return (
547         <div className="please-wait">
548           <h2>Submission received</h2>
549           <p>
550             The following players have completed judging:
551             {[...this.props.players_judged].join(', ')}
552           </p>
553           <p>
554             Still waiting for the following players:
555           </p>
556           <ul>
557             {Object.keys(this.props.players_judging).map(player => {
558               return (
559                 <li
560                   key={player}
561                 >
562                   {player}
563                   {this.props.players_judging[player] ?
564                    <span className="typing"/> : null }
565                 </li>
566               );
567             })}
568           </ul>
569           {move_on_button}
570
571         </div>
572       );
573     }
574
575     const btn_class = "ambiguity-button";
576     const btn_selected_class = btn_class + " selected";
577
578     return (
579       <div className="ambiguities">
580         <h2>Judging Answers</h2>
581         <p>
582           Click on each pair of answers that should be scored as equivalent,
583           (and click any word twice to split it out from a group). Remember,
584           what goes around comes around, so it's best to be generous when
585           judging.
586         </p>
587         {this.state.word_sets.map(set => {
588           return (
589             <div
590               className="ambiguity-group"
591               key={Array.from(set)[0]}
592             >
593               {Array.from(set).map(word => {
594                 return (
595                   <button
596                     className={this.state.selected === word ?
597                                btn_selected_class : btn_class }
598                     key={word}
599                     onClick={() => this.handle_click(word)}
600                   >
601                     {word}
602                   </button>
603                 );
604               })}
605             </div>
606           );
607         })}
608         <p>
609           Click here when done judging:<br/>
610           <button
611             onClick={() => this.handle_submit()}
612           >
613             Send
614           </button>
615         </p>
616       </div>
617     );
618   }
619 }
620
621 class ActivePrompt extends React.PureComponent {
622
623   constructor(props) {
624     super(props);
625     const items = props.prompt.items;
626
627     this.submitted = false;
628
629     this.answers = [...Array(items)].map(() => React.createRef());
630     this.answering_sent_recently = false;
631
632     this.handle_submit = this.handle_submit.bind(this);
633     this.handle_change = this.handle_change.bind(this);
634   }
635
636   handle_change(event) {
637     /* We don't care (or even look) at what the player is typing at
638      * this point. We simply want to be informed that the player _is_
639      * typing so that we can tell the server (which will tell other
640      * players) that there is activity here.
641      */
642
643     /* Rate limit so that we don't send an "answering" notification
644      * more frequently than necessary.
645      */
646     if (! this.answering_sent_recently) {
647       fetch_post_json(`answering/${this.props.prompt.id}`);
648       this.answering_sent_recently = true;
649       setTimeout(() => { this.answering_sent_recently = false; }, 1000);
650     }
651   }
652
653   async handle_submit(event) {
654     const form = event.currentTarget;
655
656     /* Prevent the default page-changing form-submission behavior. */
657     event.preventDefault();
658
659     /* And don't submit a second time. */
660     if (this.submitted)
661       return;
662
663     const response = await fetch_post_json(`answer/${this.props.prompt.id}`, {
664       answers: this.answers.map(r => r.current.value)
665     });
666     if (response.status === 200) {
667       const result = await response.json();
668       if (! result.valid) {
669         add_message("danger", result.message);
670         return;
671       }
672     } else {
673       add_message("danger", "An error occurred submitting your answers");
674       return;
675     }
676
677     /* Everything worked. Server is happy with our answers. */
678     form.reset();
679     this.submitted = true;
680   }
681
682   render() {
683     let move_on_button = null;
684     if (this.props.idle) {
685       move_on_button =(
686         <button
687           className="vote-button"
688           onClick={() => fetch_post_json(`end-answers/${this.props.prompt.id}`) }
689         >
690           Move On
691           <div className="vote-choices">
692             {[...this.props.votes].map(v => {
693               return (
694                 <div
695                   key={v}
696                   className="vote-choice"
697                 >
698                   {v}
699                 </div>
700               );
701             })}
702           </div>
703         </button>
704       );
705     }
706
707     if (this.props.players_answered.has(this.props.player.name)) {
708       return (
709         <div className="please-wait">
710           <h2>Submission received</h2>
711           <p>
712             The following players have submitted their answers:
713             {[...this.props.players_answered].join(', ')}
714           </p>
715           <p>
716           Still waiting for the following players:
717           </p>
718           <ul>
719             {Object.keys(this.props.players_answering).map(player => {
720               return (
721                 <li
722                   key={player}
723                 >
724                   {player}
725                   {this.props.players_answering[player] ?
726                    <span className="typing"/> : null }
727                 </li>
728               );
729             })}
730           </ul>
731           {move_on_button}
732
733         </div>
734       );
735     }
736
737     return (
738       <div className="active-prompt">
739         <h2>The Game of Empathy</h2>
740         <p>
741           Remember, you're trying to match your answers with
742           what the other players submit.
743           Give {this.props.prompt.items} answers for the following prompt:
744         </p>
745         <h2>{this.props.prompt.prompt}</h2>
746         <form onSubmit={this.handle_submit}>
747           {[...Array(this.props.prompt.items)].map((whocares,i) => {
748             return (
749               <div
750                 key={i}
751                 className="form-field large">
752                 <input
753                   type="text"
754                   name={`answer_${i}`}
755                   required
756                   autoComplete="off"
757                   onChange={this.handle_change}
758                   ref={this.answers[i]}
759                 />
760               </div>
761             );
762           })}
763
764           <div
765             key="submit-button"
766             className="form-field large">
767             <button type="submit">
768               Send
769             </button>
770           </div>
771
772         </form>
773       </div>
774     );
775   }
776 }
777
778 class Game extends React.PureComponent {
779   constructor(props) {
780     super(props);
781     this.state = {
782       game_info: {},
783       player_info: {},
784       other_players: [],
785       prompts: [],
786       active_prompt: null,
787       players_answered: new Set(),
788       players_answering: {},
789       answering_idle: false,
790       end_answers_votes: new Set(),
791       ambiguities: null,
792       players_judged: new Set(),
793       players_judging: {},
794       judging_idle: false,
795       end_judging_votes: new Set(),
796       scores: null
797     };
798   }
799
800   set_game_info(info) {
801     this.setState({
802       game_info: info
803     });
804   }
805
806   set_player_info(info) {
807     this.setState({
808       player_info: info
809     });
810   }
811
812   set_other_player_info(info) {
813     const other_players_copy = [...this.state.other_players];
814     const idx = other_players_copy.findIndex(o => o.id === info.id);
815     if (idx >= 0) {
816       other_players_copy[idx] = info;
817     } else {
818       other_players_copy.push(info);
819     }
820     this.setState({
821       other_players: other_players_copy
822     });
823   }
824
825   reset_game_state() {
826     this.setState({
827       prompts: [],
828       active_prompt: null,
829       players_answered: new Set(),
830       players_answering: {},
831       answering_idle: false,
832       end_answers_votes: new Set(),
833       ambiguities: null,
834       players_judged: new Set(),
835       players_judging: {},
836       judging_idle: false,
837       end_judging_votes: new Set(),
838       scores: null
839     });
840   }
841
842   set_prompts(prompts) {
843     this.setState({
844       prompts: prompts
845     });
846   }
847
848   add_or_update_prompt(prompt) {
849     const prompts_copy = [...this.state.prompts];
850     const idx = prompts_copy.findIndex(p => p.id === prompt.id);
851     if (idx >= 0) {
852       prompts_copy[idx] = prompt;
853     } else {
854       prompts_copy.push(prompt);
855     }
856     this.setState({
857       prompts: prompts_copy
858     });
859   }
860
861   set_active_prompt(prompt) {
862     this.setState({
863       active_prompt: prompt
864     });
865   }
866
867   set_players_answered(players) {
868     this.setState({
869       players_answered: new Set(players)
870     });
871   }
872
873   set_player_answered(player) {
874     const new_players_answering = {...this.state.players_answering};
875     delete new_players_answering[player];
876
877     this.setState({
878       players_answered: new Set([...this.state.players_answered, player]),
879       players_answering: new_players_answering
880     });
881   }
882
883   set_players_answering(players) {
884     const players_answering = {};
885     for (let player of players) {
886       players_answering[player] = {active: false};
887     }
888     this.setState({
889       players_answering: players_answering
890     });
891   }
892
893   set_player_answering(player) {
894     this.setState({
895       players_answering: {
896         ...this.state.players_answering,
897         [player]: {active: true}
898       }
899     });
900   }
901
902   set_answering_idle(value) {
903     this.setState({
904       answering_idle: value
905     });
906   }
907
908   set_end_answers(players) {
909     this.setState({
910       end_answers_votes: new Set(players)
911     });
912   }
913
914   set_player_vote_end_answers(player) {
915     this.setState({
916       end_answers_votes: new Set([...this.state.end_answers_votes, player])
917     });
918   }
919
920   set_player_unvote_end_answers(player) {
921     this.setState({
922       end_answers_votes: new Set([...this.state.end_answers_votes].filter(p => p !== player))
923     });
924   }
925
926   set_ambiguities(ambiguities) {
927     this.setState({
928       ambiguities: ambiguities
929     });
930   }
931
932   set_players_judged(players) {
933     this.setState({
934       players_judged: new Set(players)
935     });
936   }
937
938   set_player_judged(player) {
939     const new_players_judging = {...this.state.players_judging};
940     delete new_players_judging[player];
941
942     this.setState({
943       players_judged: new Set([...this.state.players_judged, player]),
944       players_judging: new_players_judging
945     });
946   }
947
948   set_players_judging(players) {
949     const players_judging = {};
950     for (let player of players) {
951       players_judging[player] = {active: false};
952     }
953     this.setState({
954       players_judging: players_judging
955     });
956   }
957
958   set_player_judging(player) {
959     this.setState({
960       players_judging: {
961         ...this.state.players_judging,
962         [player]: {active: true}
963       }
964     });
965   }
966
967   set_judging_idle(value) {
968     console.log("Setting judging idle to " + value);
969     this.setState({
970       judging_idle: value
971     });
972   }
973
974   set_end_judging(players) {
975     this.setState({
976       end_judging_votes: new Set(players)
977     });
978   }
979
980   set_player_vote_end_judging(player) {
981     this.setState({
982       end_judging_votes: new Set([...this.state.end_judging_votes, player])
983     });
984   }
985
986   set_player_unvote_end_judging(player) {
987     this.setState({
988       end_judging_votes: new Set([...this.state.end_judging_votes].filter(p => p !== player))
989     });
990   }
991
992   set_scores(scores) {
993     this.setState({
994       scores: scores
995     });
996   }
997
998   render() {
999     const state = this.state;
1000     const players_total = 1 + state.other_players.length;
1001
1002     if (state.scores) {
1003       return (
1004         <div className="scores">
1005           <h2>Scores</h2>
1006           <ul>
1007             {state.scores.scores.map(score => {
1008               return (
1009                 <li key={score.player}>
1010                   {score.player}: {score.score}
1011                 </li>
1012               );
1013             })}
1014           </ul>
1015           <h2>Words submitted</h2>
1016           <ul>
1017             {state.scores.words.map(word => {
1018               return (
1019                 <li key={word.word}>
1020                   {`${word.word}: ${word.players.join(', ')}`}
1021                 </li>
1022               );
1023             })}
1024           </ul>
1025           <button
1026             className="new-game"
1027             onClick={() => fetch_post_json('reset') }
1028           >
1029             New Game
1030           </button>
1031         </div>
1032       );
1033     }
1034
1035     if (state.ambiguities){
1036       return <Ambiguities
1037                prompt={state.active_prompt}
1038                words={state.ambiguities}
1039                player={state.player_info}
1040                players_judged={state.players_judged}
1041                players_judging={state.players_judging}
1042                idle={state.judging_idle}
1043                votes={state.end_judging_votes}
1044              />;
1045     }
1046
1047     if (state.active_prompt) {
1048       return <ActivePrompt
1049                prompt={state.active_prompt}
1050                player={state.player_info}
1051                players_answered={state.players_answered}
1052                players_answering={state.players_answering}
1053                idle={state.answering_idle}
1054                votes={state.end_answers_votes}
1055              />;
1056     }
1057
1058     return [
1059       <GameInfo
1060         key="game-info"
1061         id={state.game_info.id}
1062         url={state.game_info.url}
1063       />,
1064       <PlayerInfo
1065         key="player-info"
1066         game={this}
1067         player={state.player_info}
1068         other_players={state.other_players}
1069       />,
1070       <p key="spacer"></p>,
1071       <CategoryRequest
1072         key="category-request"
1073       />,
1074       <PromptOptions
1075         key="prompts"
1076         prompts={state.prompts}
1077       />,
1078       <LetsPlay
1079         key="lets-play"
1080         num_players={1+state.other_players.length}
1081         prompts={state.prompts}
1082       />
1083     ];
1084   }
1085 }
1086
1087 ReactDOM.render(<Game
1088                   ref={(me) => window.game = me}
1089                 />, document.getElementById("empathy"));