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