master 99d6007715d2 cached
176 files
1.0 MB
375.7k tokens
376 symbols
1 requests
Download .txt
Showing preview only (1,079K chars total). Download the full file or copy to clipboard to get everything.
Repository: googlecreativelab/webvr-musicalforest
Branch: master
Commit: 99d6007715d2
Files: 176
Total size: 1.0 MB

Directory structure:
gitextract_yh_t2yfb/

├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app.yaml
├── backend/
│   ├── .babelrc
│   ├── .gitignore
│   ├── README.md
│   ├── package.json
│   └── src/
│       ├── config.js
│       ├── datastore/
│       │   ├── datastore-constants.js
│       │   └── index.js
│       ├── index.js
│       ├── logger/
│       │   ├── cloud-logger.js
│       │   └── index.js
│       ├── messages/
│       │   ├── index.js
│       │   ├── message-actions.js
│       │   ├── message-constants.js
│       │   ├── message-handler.js
│       │   ├── message-rate-limiter.js
│       │   ├── message-sagas.js
│       │   ├── message-schema.js
│       │   └── message-validator.js
│       ├── pubsub/
│       │   ├── index.js
│       │   └── pubsub-client.js
│       ├── rooms/
│       │   ├── index.js
│       │   ├── room-data-actions.js
│       │   ├── room-data-constants.js
│       │   ├── room-data-reducer.js
│       │   ├── room-names.js
│       │   ├── room-sagas.js
│       │   ├── room-state-actions.js
│       │   ├── room-state-constants.js
│       │   ├── room-state-machine.js
│       │   ├── room-state-reducer.js
│       │   └── room-state-utils.js
│       ├── s11n.js
│       ├── server/
│       │   ├── app-server.js
│       │   ├── balancer.js
│       │   ├── index.js
│       │   ├── server-actions.js
│       │   ├── server-constants.js
│       │   ├── server-reducer.js
│       │   ├── server-sagas.js
│       │   ├── server-setup.js
│       │   ├── server-startup.js
│       │   ├── server-sync-handlers.js
│       │   ├── server-sync.js
│       │   ├── target-server-utils.js
│       │   └── websocket-server.js
│       ├── spheres/
│       │   ├── index.js
│       │   ├── sphere-constants.js
│       │   └── trees/
│       │       ├── 0.js
│       │       ├── 1.js
│       │       ├── 2.js
│       │       ├── 3.js
│       │       ├── 4.js
│       │       └── 5.js
│       ├── store.js
│       └── utils/
│           ├── index.js
│           ├── reducer-utils.js
│           ├── saga-utils.js
│           ├── string-utils.js
│           ├── url-utils.js
│           └── websocket-utils.js
├── config.js
├── config.template
├── index.html
├── js/
│   ├── ascene.js
│   ├── components/
│   │   ├── background-objects.js
│   │   ├── ball.js
│   │   ├── bg-tree-ring-material.js
│   │   ├── clicker.js
│   │   ├── controller-material.js
│   │   ├── controllers.js
│   │   ├── copresence-server-messages.js
│   │   ├── copresence-server.js
│   │   ├── daydream-manager.js
│   │   ├── daydream-pointer.js
│   │   ├── fake-light.js
│   │   ├── ga.js
│   │   ├── gaze.js
│   │   ├── grab-move.js
│   │   ├── haptics.js
│   │   ├── headset-material.js
│   │   ├── listener.js
│   │   ├── palette.js
│   │   ├── proximity-check.js
│   │   ├── quaternion.js
│   │   ├── smooth-motion.js
│   │   ├── teleport.js
│   │   ├── tone.js
│   │   ├── tool-tips.js
│   │   ├── touch-color.js
│   │   ├── tree.js
│   │   └── wasd-boundaries.js
│   ├── core/
│   │   ├── color-set.js
│   │   ├── colors.js
│   │   ├── instrument.js
│   │   ├── instruments.js
│   │   ├── shape-data.js
│   │   └── shapes.js
│   ├── index.js
│   ├── notes/
│   │   ├── note-head-cube.js
│   │   ├── note-head-sphere.js
│   │   ├── note-head-tetra.js
│   │   ├── note-head.js
│   │   ├── note-shadow-cube.js
│   │   ├── note-shadow-sphere.js
│   │   ├── note-shadow-tetra.js
│   │   ├── note-shadow.js
│   │   ├── note-stem.js
│   │   └── note.js
│   ├── orientation-arm-model.js
│   ├── shaders/
│   │   ├── ball-shader.js
│   │   ├── bg-tree-shader.js
│   │   ├── circle-shader.js
│   │   └── shader-chunks.js
│   ├── splash.js
│   ├── util/
│   │   ├── browserCheck.js
│   │   ├── random-range-1d.js
│   │   ├── random-range-3d.js
│   │   ├── tetrahedron.js
│   │   └── trace.js
│   └── util.js
├── package.json
├── python/
│   ├── __init__.py
│   ├── base/
│   │   ├── __init__.py
│   │   ├── api_fixer.py
│   │   ├── api_fixer_test.py
│   │   ├── constants.py
│   │   ├── handlers.py
│   │   ├── handlers_test.py
│   │   ├── models.py
│   │   ├── models_test.py
│   │   ├── xsrf.py
│   │   └── xsrf_test.py
│   ├── country_servers.py
│   ├── handlers.py
│   ├── main.py
│   └── main_test.py
├── static/
│   ├── audio/
│   │   ├── bg.ogg
│   │   ├── marimba/
│   │   │   ├── 0.ogg
│   │   │   ├── 1.ogg
│   │   │   ├── 2.ogg
│   │   │   ├── 3.ogg
│   │   │   ├── 4.ogg
│   │   │   └── 5.ogg
│   │   ├── percussion/
│   │   │   ├── 0.ogg
│   │   │   ├── 1.ogg
│   │   │   ├── 2.ogg
│   │   │   ├── 3.ogg
│   │   │   ├── 4.ogg
│   │   │   └── 5.ogg
│   │   └── voice/
│   │       ├── 0.ogg
│   │       ├── 1.ogg
│   │       ├── 2.ogg
│   │       ├── 3.ogg
│   │       ├── 4.ogg
│   │       └── 5.ogg
│   └── models/
│       ├── bg-tree-1.obj
│       ├── bg-tree-2.obj
│       ├── bg-tree-3.obj
│       ├── bg-tree-ring.obj
│       ├── controller.dae
│       ├── door-frame.obj
│       ├── door-glow.obj
│       ├── door.dae
│       ├── headset.dae
│       ├── stump-shadow.obj
│       ├── stump.obj
│       └── target.obj
├── style/
│   └── splash.scss
└── third_party/
    └── aframe-daydream-controller-component/
        ├── LICENSE
        ├── METADATA
        └── daydream-controller.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
/node_modules
/.idea
.DS_Store
build/main.js
*.pyc
build/
*.swp
.sass-cache
npm-debug.log

*.asd


================================================
FILE: CONTRIBUTING.md
================================================
# How to contribute

We'd love to accept your patches and contributions to this project. There are
just a few small guidelines you need to follow.

## Contributor License Agreement

Contributions to this project must be accompanied by a Contributor License
Agreement. You (or your employer) retain the copyright to your contribution,
this simply gives us permission to use and redistribute your contributions as
part of the project. Head over to <https://cla.developers.google.com/> to see
your current agreements on file or to sign a new one.

You generally only need to submit a CLA once, so if you've already submitted one
(even if it was for a different project), you probably don't need to do it
again.

## Code reviews

All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult [GitHub Help] for more
information on using pull requests.

[GitHub Help]: https://help.github.com/articles/about-pull-requests/

================================================
FILE: LICENSE
================================================

                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

================================================
FILE: README.md
================================================
<table>
  <tr>
    <td>
      This project is no longer actively maintained by the Google Creative Lab but remains here in a read-only Archive mode so that it can continue to assist developers that may find the examples helpful. We aren’t able to address all pull requests or bug reports but outstanding issues will remain in read-only mode for reference purposes. Also, please note that some of the dependencies may not be up to date and there hasn’t been any QA done in a while so your mileage may vary.
      <br><br>
      For more details on how Archiving affects Github repositories see <a href="https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-archiving-repositories">this documentation </a>.
      <br><br>
      <b>We welcome users to fork this repository</b> should there be more useful, community-driven efforts that can help continue what this project began.
    </td>
  </tr>
</table>

# Musical Forest

Musical Forest is a multiplayer musical experiment in virtual reality. It uses copresence —the synchronization of multiple people in a virtual space— to allow people to play music together in VR. WebSockets are used to sync all of the connected players in real-time. 

Musical Forest, a [WebVR Experiment](https://webvrexperiments.com/).
<br>
<br>

## Basic Interaction 

Each of the shapes triggers a note when it’s hit. Users can navigate the space, play notes and also hear and play with all of the other players in the space at the same time. 
<br>
![alt text](https://forest.webvrexperiments.com/static/img/MusicalForest.gif "The Musical Forest, mixed reality interaction example")
<br>
<br>

## Objects & Audio

There are three different shapes of musical objects corresponding to three different sets of sounds. Each set has six notes, chosen from a pentatonic scale. 

* Spheres: Percussion (Conga, Woodblock, COWBELL!)
* Triangular Pyramids: Voice + Flute
* Cubes: Marimba

Sounds are positioned in 3D space using the Web Audio API’s PannerNode. 
<br>
<br>

## Headsets & Interaction Models

Musical Forest responsively adapts features depending on the capabilities of the VR Hardware. 

### Vive/Oculus

Play: hit the shapes with your controller to hear its sound. The volume of the sound changes depending on how hard you hit it. <br>
Create: tap the trigger to create a new shape. Rotate the circular pad to change the note. <br>
Rearrange: place the controller over an existing object, press and hold the trigger to grab it. Move your controller and release to move it to a new space. Hovering your controller over an object and rotating the circular pad will change the type of shape and it’s sound.<br>
Navigation: move within the bounds of your roomscale environment to interact with the objects within the experience.

### Daydream

Play: Hit the shapes with your controller to hear their sounds. <br>
Navigation: point the Daydream controller at the ground and a circle will appear. Press the main button on the controller to teleport to that highlighted spot.

### Cardboard

Play: gaze at an object and see it glow. Tap the interaction button to hear that object.<br>
Navigation: gaze at the ground and a circle will appear. Tap the interaction button to teleport to the highlighted spot.

### Magic Window

Interaction: tap any object to hear the sound of that object. <br>
Navigation: gaze at the ground and a circle will appear. Tap anywhere to teleport to where the reticle is pointing.

### Desktop

Interaction: use the mouse to click any object to hear its sound. The volume of the sound is dictated by the object’s distance from the user.<br>
Navigation: use the WASD keys on the keyboard. Use the mouse to change the field of view by clicking in empty space and dragging. 
<br>
<br>

## Technologies Used
### Frontend

Musical Forest uses [A-Frame](https://aframe.io) which is built with the WebVR standard and [Tone.js](https://github.com/Tonejs/Tone.js/) for sound.

### Backend

The backend is developed in Node.js. To get a full overview of the technologies and libraries used, see the [backend readme](backend/README.md#Description)
<br>
<br>

## Running the Frontend Code

Download the source code, and install all dependencies by running `npm install`. To run the frontend, run `npm start`, this will start a local webserver using `budo` connecting to the default backend server. If you have a local backend server running, append `?server=localhost` to the url. 
<br>
<br>

## Acknowledgements

[Manny Tan](https://github.com/mannytan), [Igor Clark](https://github.com/igorclark), [Yotam Mann](https://github.com/tambien), [Alexander Chen](https://github.com/alexanderchen), [Jonas Jongejan](https://github.com/halfdanj), [Jeremy Abel](https://github.com/jeremyabel), [Saad Moosajee](https://github.com/moosajee), Alex Jacobo-Blonder, [Ryan Burke](https://github.com/ryburke), and many others at Google Creative Lab.


================================================
FILE: app.yaml
================================================
# Copyright 2017 Google Inc.
#
#   Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
#   Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
# limitations under the License.

###########################################################################
# DO NOT MODIFY THIS FILE WITHOUT UNDERSTANDING THE SECURITY IMPLICATIONS #
###########################################################################

# The version is automatically generated based on the current git hash.
# If there are uncommitted changes, a '-dev' suffix will be added. You do
# not need to modify it here.
runtime: python27
api_version: 1
threadsafe: true

handlers:
#        - url: /
#          static_files: "index.html"
#          secure: always
#          upload: index\.html
#          http_headers:
#            X-Frame-Options: "DENY"
#            Strict-Transport-Security: "max-age=2592000; includeSubdomains"
#            X-Content-Type-Options: "nosniff"
#            X-XSS-Protection: "1; mode=block"

        - url: /build/
          static_dir: build/
          secure: always
          http_headers:
            X-Frame-Options: "DENY"
            Strict-Transport-Security: "max-age=2592000; includeSubdomains"
            X-Content-Type-Options: "nosniff"
            X-XSS-Protection: "1; mode=block"

        - url: /static/
          static_dir: static/
          secure: always
          http_headers:
            X-Frame-Options: "DENY"
            Strict-Transport-Security: "max-age=2592000; includeSubdomains"
            X-Content-Type-Options: "nosniff"
            X-XSS-Protection: "1; mode=block"



# All URLs should be mapped via the *_ROUTES variables in the src/main.py file.
# See https://webapp-improved.appspot.com/guide/routing.html for information on
# how URLs are routed in the webapp2 framework. Do not add additional handlers
# directly here.
        - url: /.*
          script: python.main.app
          secure: always

libraries:
        - name: django
          version: latest

        - name: jinja2
          version: latest

        - name: webapp2
          version: latest

skip_files:
         - ^(.*/)?#.*#$
         - ^(.*/)?.*~$
         - ^(.*/)?.*\.py[co]$
         - ^(.*/)?.*/RCS/.*$
         - ^(.*/)?\..*$
         - README
         - util.sh
         - run_tests.py
         - .*_test.py
         - js/.*
         - backend/.*
         - ^node_modules/(.*/)?
         - ^(.*/)?app\.yaml
         - ^(.*/)?app\.yml
         - ^\.git/.*


================================================
FILE: backend/.babelrc
================================================
{
  "presets": ["es2015", "stage-2"],
  "plugins": []
}


================================================
FILE: backend/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*

# tmp/testing
scratch
tmp
*.bak

# vim swap files
*.swp
*.swo

# Mac filesystem entries
.DS_Store

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# node-waf configuration
.lock-wscript

# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release

# babel-translated es5 output
dist

# Dependency directories
node_modules
jspm_packages

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# vagrant anything
.vagrant

# allow .gitignore files
!.gitignore


================================================
FILE: backend/README.md
================================================
# webvr-experiments: Musical Forest back-end

## Contents

* [Description](#description)
* [Running the app locally](#running-the-app-locally)
* [Deploying in production](#deploying-in-production)
* [How the app works](#how-the-app-works)
  * [Basic operation](#basic-operation)
  * [Managing state](#managing-state)
  * [Starting the server](#starting-the-server)
  * [Handling SSL connections](#handling-ssl-connections)
  * [Managing connections](#managing-connections)
  * [Managing peer servers](#managing-peer-servers)
  * [Managing rooms](#managing-rooms)
  * [Handling client messages](#handling-client-messages)
* [Connecting clients to the server](#connecting-clients-to-the-server)
* [Messages](#messages)
* [URLs](#urls)
* [Interacting in rooms](#interacting-in-rooms)

---

## Description

This is the backend server for the Chrome WebVR Experiment [Musical Forest](https://forest.webvrexperiments.com/). It's a dynamic room-based [WebSocket](https://en.wikipedia.org/wiki/WebSocket) server app using Google Cloud [PubSub](https://cloud.google.com/pubsub/) and [Datastore](https://cloud.google.com/datastore/). It's written in Javascript ES6 using [`node.js`](https://nodejs.org/) and the following [`npm`](https://www.npmjs.com/ )modules:

* `uws` websocket server

* `redux` and `javascript-state-machine` for state management

* `redux-sagas` for managing asynchronous/long-running tasks and composite async actions

* `@google-cloud/*` for using GCP services

* `http-proxy` and `hashring` to distribute connections consistently between app instances

* `fast-ratelimit` to hard-limit per-client message rates

* `tracer` for logging


---

## Running the app locally

#### Install prerequisites

* [Install gcloud](https://cloud.google.com/sdk/downloads)

* `node.js` LTS version 6 + `npm` (Mac: `brew install node@6 npm` - [notes about installing `node` on Mac with Homebrew](http://apple.stackexchange.com/questions/171530/how-do-i-downgrade-node-or-install-a-specific-previous-version-using-homebrew/207883))

* `gcc` C compiler for native `node.js` modules (Xcode 8.1+ if running on Mac)

* [`jq`](https://stedolan.github.io/jq/) (Mac: `brew install jq`)


#### Authenticate with GCP

```
gcloud auth login
gcloud config set project <project-id>
gcloud auth application-default login
```

#### Install node.js modules

`npm install --no-optional`

#### Run the app

Start the `datastore` & `pubsub` emulators:

`npm run emulators`

In a new shell, start the app in dev mode using the emulators:

`npm run start_emulated`

You can now connect to the server from the main web app by appending `?server=ws://localhost:9100` to the URL.

#### Test a connection to the app from command line (optional)

Install a solid command-line websocket client:

* Install [go](https://golang.org/) (Mac: `brew install go # make sure to set up a ${GOPATH} dir & env var`)
* `go get -u github.com/hashrocket/ws`

Connect to a websocket:

* `ws ws://localhost:9100/<viewer_type>` (see [URLs](#urls) below regarding viewer types)
* _paste in a message, e.g. `{"t":"g_s","d":{"spId":"973c742d-866f-4b25-9ae6-8830e7f13e59"}}` as above_

#### Run multiple local app instances (optional)

The app defaults to using ports `8100` for the application server and `9100` for the inter-node connection balancer (see `src/server/server-startup.js` and `src/server/balancer.js`).

To run multiple instances, `export WS_SERVER_PORT=<other-server-port>` and `export WS_BALANCER_PORT=<other-balancer-port>` in a new shell before running `npm run start_emulated`. Instances will coordinate via the local PubSub and Datastore emulators.

---

## Deploying in production

The app is designed to be deployed as a series of single-threaded application instances (pods) running in a Docker container cluster via [Kubernetes](https://kubernetes.io/) on [Google Container Engine](https://cloud.google.com/container-engine/). Deploying and running clustered applications is beyond the scope of this document, but the following configuration and setup points are necessary to run this app in such environments.

You'll need to create a `Dockerfile` for your environment along with an `entrypoint` script to run the application on the pods, and modify the application configuration as follows:

1. Your `entrypoint` script must `export` the following environment variables:

    * `PROJECT_ID` - your Google Cloud Platform project name
  
    * `ENVIRONMENT_NAME` - a unique identifier to separate different deployment environments, and keeps Datastore and PubSub resources separate for each deployment within a project. As an example, running the app locally via `package.json` uses your computer's `hostname`; you might want to name a particular deployment by region or type
  
    * `LOCAL_IP_ADDRESS` - the private-range IP address by which each pod is accessible and identifies itself to other nodes in the cluster, used by pods to health-check each other when necessary

2. When automatically scaled up or down, application nodes synchronize with new and removed nodes using a PubSub topic. The name of this topic is constructed from your `ENVIRONMENT_NAME` at startup in `src/config.js`

3. Set `PRODUCTION_ENVIRONMENT_PROJECT_ID` and `PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN` in `src/config.js` to reflect the Google Cloud Platform project name and [WebSocket Origin header](https://en.wikipedia.org/wiki/WebSocket#Protocol_handshake) required for connections to your production cluster

Refer to the [Container Engine](https://cloud.google.com/container-engine/docs/) and [Kubernetes](https://kubernetes.io/docs/home/) documentation for further information on how to set up and manage clusters, deployments, services, etc.


---

## How the app works

##### Basic operation

The app accepts client connections to its websocket server (`src/server/websocket-server.js`), parses the requested URL to work out headset type and whether a specific room has been chosen, and then tries to connect the client to a room. It maintains state about which clients and spheres are in which room, which rooms are full or available, and handles all communications from and to clients in all rooms.

##### Managing state

The app holds state in a `redux` state store (`src/store.js`), mutated in reducers (`src/*/*-reducer.js`) via messages `dispatch()`'d to the store, and accessed from the store in async or composite action sequences (`src/server/server-sagas.js`, `src/rooms/room-sagas.js`, `src/messages/message-sagas.js`) using methods provided by the `redux-sagas` module.

##### Starting the server

Servers are started (`src/server/server-startup.js`) via a series of composite actions (`src/server/server-sagas.js`, `src/server/server-setup.js`) defined as `redux-sagas` generator functions, initiated by a server setup action dispatched to the `redux` store at startup (`src/index.js`).

The server requires the environment variables `ENVIRONMENT_NAME`, `LOCAL_IP_ADDRESS` and `PROJECT_ID` in order to run. These are set by the `npm` and `gke` startup tasks, according to where the app's being run.

##### Handling SSL connections

If the environment variable `USE_SSL` is set to `true`, the server requires the environment variable `SSL_CERT_HOST_NAME` to be set too, and retrieves the SSL certificate for that hostname on startup from a [Google Cloud Storage](https://cloud.google.com/storage/) (GCS) bucket in the GCP project, named `<project-name>-ssl`. The full certificate chain file (named `fullchain.pem`) and the private key file (`privkey.pem`) must be stored in a sub-folder of that GCS bucket with the same name as the required hostname.


##### Managing connections
All connections to a given room are handled by the same instance of the server. The app manages this by running a public/client-facing 'load-balancer' (`src/server/balancer.js`) which proxies client connections either to a local or peer websocket app server, according to which server is associated with the chosen room name in a persistent hash-ring. (See the `hashring` [docs](https://github.com/3rd-Eden/node-hashring) for implementation, and a helpful [explanation](http://blog.plasmaconduit.com/consistent-hashing/).) The hash-ring is stored in the server state (`src/server/server-reducer.js`).

When a connection arrives, the server takes the specified room name or chooses one dynamically according to which rooms are available locally (using `avails` in `src/rooms/room-data-reducer.js` and the state machine in `src/rooms/room-state-machine.js'). It then looks up which server should handle the connection in the hash-ring and proxies the connection to that server.

References to all websocket connections held by a given server are kept in the server state (`src/server/server-reducer.js`). The only non-store-managed state consists of properties (`id` and current-room info) set directly on websocket connection objects by the websocket server.

##### Managing peer servers
In order to maintain a list of peers during automatic scaling of the app in production, the server connects to a PubSub 'sync channel' on startup (`src/server/server-setup.js`), and listens out for sync 'heartbeat' messages from other servers on the channel. It maintains a list of which servers are connected, adding newly-arrived servers to the hash-ring and removing them from it when they time out (stop sending sync heartbeats for a given period).

Each server instance periodically writes information about its state and its PubSub subscription to a ServerSubscriptionInfo record in the Datastore (`src/server/server-sagas.js`), allowing the administrator to retrieve aggregate status info, and enabling the app to check up on timed-out or otherwise dead app instances, removing their status info & PubSub subscriptions as appropriate (also in `src/server/server-sagas.js`).

##### Managing rooms

The server manages room status info (available, full, setting up, etc) using a state machine (`src/rooms/room-state-machine.js`) for each room, initialised at app startup and accessed via the `redux` store. A series of long-running tasks (`src/rooms/room-sagas.js`) handles activity on the rooms (initialising room content, starting/stopping heartbeats, client activity and sphere hold timeouts) using generator functions managed using the `redux-sagas` module.

##### Handling client messages

Clients are restricted to a fixed set of messages which are specified in a JSON schema (`src/messages/message-schema.js`). The websocket server passes received client messages to a message-handler module (`src/messages/message-handler.js`). The message-handler validates incoming messages against the schema (`src/messages/message-validator.js`) and dispatches valid messages to the `redux` state store, using messages constructed from the schema (`src/messages/message-actions.js`). Invalid messages are dropped before getting to dispatch, so any message that gets through at least passes validation.

A series of long-running tasks implemented as `redux-sagas` (`src/messages/message-sagas.js`) handle the validated messages and carry out the requested actions, i.e. sending client position updates and interacting with spheres.

* All possible message interactions between clients and servers are specified in the JSON schema and carried out in the "sagas" - any messages not specified like so are silently dropped.

* Any superfluous information in messages received from clients is silently dropped.

* The websocket server rate-limits client messages on both a per-message-type and per-client basis. Default rates are specified in `RATE_LIMIT_INFO` constants (`src/config.js`), and overridden by values retrieved from `PerEnvironmentRateLimitInfo` Datastore records on startup if present (`src/server/server-sagas.js`). You can request the app reload the limit values from Datastore by sending a `SYNC_MESSAGES.LOAD_RATE_LIMITER_INFO` message to the app via PubSub. (That's a deploy/admin task not implemented in the app source, but the mechanism is visible in `sendSyncMessageOfType()` in `src/server/server-sync.js`.) Messages above the thresholds are silently dropped.

* Any messages received from clients with a 'viewer' headset-type are silently dropped.


---

## Connecting clients to the server

The app accepts standard WebSocket connections to "rooms" in the forest. The server receives messages over the WebSocket, sends responses and broadcasts messages from other occupants of the room all over the same connection.

----

## Messages

Here's an example of the message format:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",	// 'from' id of client or server sending the message
  "m": {	// message
    "t": "<type>",
    "d": "<data>"
  }
}
```

Message labels (e.g. `f` or `m` above) are set as constants in the file `src/messages/message-constants.js`. In front-end and back-end code, messages are always constructed and consumed using the labels as property names rather than fixed properties, so that the values used can be changed to friendly names during development if required, and restricted to short strings for use in production.

---

## URLs

Clients make connections via URLs of the format `ws://<host>:<port>/<viewer_type>/<room_name>`.

`<viewer_type>` can be `3dof`, `6dof` or `viewer`.

Any action messages sent by `viewer` connections are dropped by the server when running in production.

* To join a specific room, supply both the `<viewer_type>` and `<room_name>` URL components.
* To have a room chosen for you, supply only the `<viewer_type>` component.

Any other URLs are invalid and connections to them will be closed immediately with an `INVALID_URL` (`i_u`) message:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "i_u"	// invalid_url
  }
}
```

---

## Interacting in rooms

* All interactions with the server are carried out using messages in the above format over the websocket connection.
* Clients don't need to identify themselves with `<from>` fields, as IDs are associated directly with client websocket connections on the server.
* The server validates messages received from clients against the appropriate JSON schema for their `<type>`, and immediately drops anything non-conforming, according to the schema in `src/messages/message-schema.js`. It also removes any superfluous content/properties in the messages before storing anything to the room state or broadcasting updates to other clients in the room.

### Joining a room

When the first client joins a room, the server kicks off a heartbeat, and sends an incrementing `ROOM_HEARTBEAT` (`r_h`) sync message to all clients present in the room, every 5 seconds:

```
{
  "f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
  "m": {
    "t": "r_h",
    "d": {
      "c": 3,	// heartbeat count since room start
      "s": 15	// heartbeat seconds since room start
    }
  }
}
```

When a new client joins a room, it gets sent a `CONNECTION_INFO` (`c_i`) message containing its ID and the ID of the server it's connected to:

```
{
  "f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
  "m": {
    "t": "c_i",
    "d": {
      "cId": "cb439961-3b57-4403-bcc5-208b47565dd1",    // clientId
      "srId": "a738cbe0-4dac-4389-aa20-1e6a86c166a9"    // serverId
    }
  }
}
```

... followed immediately by a `ROOM_STATUS_INFO` (`r_s_i`) message containing the `ROOM_NAME` (`r_n`), a list of other occupants of the room grouped by viewer type and keyed by their IDs, and a list of spheres in the room with their tones & positions:

```
{
  "f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
  "m": {
    "t": "r_s_i",
    "d": {
      "rn": "ctyw",		// room name
      "sb": 1,			// soundbank
      "c": {			// clients, grouped by type
        "3dof": [
          "cb439961-3b57-4403-bcc5-208b47565dd1"
        ]
      },
      "s": {	// spheres
        "6567cbaa-e6c5-457f-b338-2949b13ff17a": {
          "t": 4,	// tone
          "p": {	// position
            "x": -0.5858704723117225,
            "y": 1.4647926895710155,
            "z": 0.2328255217809887
          }
        },
        "0e19b0dd-a90b-4fc8-93d5-24a96365ce2c": {
          "t": 6,
          "p": {
            "x": -0.07124214717156585,
            "y": 0.9642806694401945,
            "z": 1.181824016202171
          }
        }
      }
    }
  }
}

```

Existing clients get a `ROOM_CLIENT_JOIN` (`r_c_j`) message indicating a new client has joined along with its id:

```
{
  "f": "a738cbe0-4dac-4389-aa20-1e6a86c166a9",
  "m": {
    "t": "r_c_j",
    "d": {
      "cId": "6211a260-d86e-4db9-be00-bd2eba1c2794",   // clientId
      "ht": "3dof"                                     // headsetType
    }
  }
}
```

If the server is full, overloaded or in the process of scaling, the server sends a `BUSY_TRY_AGAIN` (`b_t_a`) message, in which case the client should just try to connect again:

```
{
  "f": "d02922d2-6db0-4ad1-93e1-0d778250461e",
  "m": {
    "t": "b_t_a",
    "d": {}
  }
}
```

### Leaving a room

Clients can disconnect by sending a `ROOM_EXIT` (`r_e`) message:

```
{
  "t": "e_r"
}
```

in which case the server will send a `ROOM_EXIT_SUCCESS` (`r_e_s`) message and close the connection:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "r_e_s",
    "d": {
      "rn": "batr"	// room name
    }
  }
}
```

The client can also just close the websocket connection. 😏

When a client disconnects, its positional data is removed from the room state and any remaining clients get a `ROOM_CLIENT_EXIT` (`r_c_e`) message telling them it's left:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "r_c_e",
    "d": {
      "cId": "56252c73-2801-4fff-86f9-d214bdd52b94"
    }
  }
}

```

### In-room actions

Once connected to a room, clients can send positional info, and interact with spheres in the room. All interactions are carried out using the following messages.

#### `UPDATE_CLIENT_COORDS` (`u_c_c`)

This is the most frequent message clients send, notifying a change in client position:

```
{
  "t": "u_c_c",
  "d": {
      "r": {	// right
        "r": {		// rotation
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {	// position
          "x": 19.54384,
          "y": 25.3323,
          "z": 9.81832
        }
      },
      "l": {	// left
        "r": {
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {
          "x": 19.54384,
          "y": 25.3323,
          "z": 9.81832
        }
      },
      "h": {	// head
        "r": {
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {
          "z": 79.618,
          "y": 0,
          "x": 19.888
        }
      }
    }
}

```

These are broadcast to other clients in the same room as `ROOM_CLIENT_COORDS_UPDATED` (`r_c_c_u`), providing rotation (`r`) and position (`p`) coordinates for each of head (`h`), left hand (`l`) and right hand (`r`):

```
{
  "f": "41bd2e10-0f67-4b14-acee-84eeed1764b7",
  "m": {
    "t": "r_c_c_u",
    "d": {
      "r": {
        "r": {
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {
          "x": 19.54384,
          "y": 25.3323,
          "z": 9.81832
        }
      },
      "l": {
        "r": {
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {
          "x": 19.54384,
          "y": 25.3323,
          "z": 9.81832
        }
      },
      "h": {
        "r": {
          "x": 9.234324,
          "y": 55.232423,
          "z": 7.234234
        },
        "p": {
          "z": 79.618,
          "y": 0,
          "x": 19.888
        }
      }
    }
  }
}
```

Clients can optionally include sphere positions in the `UPDATE_CLIENT_COORDS` messages, for any spheres they currently hold (see below). To do so, they add an extra `spheres` object at the same level as the `head`, `left` and `right`:

```
{
  "f": "41bd2e10-0f67-4b14-acee-84eeed1764b7",
  "m": {
    "t": "r_c_c_u",
    "d": {
      "s": [	// spheres
        {
          "spId": "df0a769c-653d-44ec-9f17-e5a9d2c0f167",
          "p": {
            "x": 156,
            "y": 22,
            "z": 10
          }
        },
        {
          "spId": "69143b9a-1342-4aec-a7da-1daac29cee3d",
          "p": {
            "x": 2,
            "y": 3,
            "z": 4
          }
        }
      ],
      "r": { [...] },  // right
      "l": { [...] },  // left
      "h": { [...] }  // head
    }
  }
}
```

Clients can interact with spheres in the room by sending messages as follows.

#### `CREATE_SPHERE_OF_TONE_AT_POSITION` (`c_s_o_t_a_p`)

```
{
  "t": "c_s_o_t_a_p",
  "d": {
    "t": 0,		// tone
    "p": {		// position
      "x": 1,
      "y": 1,
      "z": 3
    }
  }
}
```

On successful creation, the server sends a `CREATE_SPHERE_SUCESS` (`c_s_s`) message:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",	// from <server-id>
  "m": {
    "t": "c_s_s",
    "d": {
      "spId": "cad0a7d4-a055-49b2-a3f8-4e08d636dbce"
    }
  }
}
```

#### `GRAB_SPHERE` (`g_s`)

```
{
  "t": "g_s",
  "d": {
    "spId": "090253c9-1c68-44c2-8aac-dba707c6aef7"
  }
}
```

On successful grab, server sends `GRAB_SPHERE_SUCCESS` (`g_s_s`):

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "g_s_s",
    "d": {
      "spId": "31654801-9e61-4a5e-bdd6-a29e8ecd1001"
    }
  }
}
```

Before accepting a grab, the server checks its state to see whether the sphere exists and whether it's already held by any other clients. Any problems are communicated back to the client. Possible errors are:

`NON_EXISTENT_SPHERE` (`n_e_s`)

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "n_e_s",
    "d": {
      "spId": "66b93b19-03d6-4220-b44f-5b33d5d59269"    // sphereId
    }
  }
}
```

`CLIENT_HOLDING_SPHERE` (`c_h_s`):

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "c_h_s",
    "d": {
      "spId": "29312c56-b1a5-4530-8c7b-2c968e158683",
      "hId": "1eec65a7-2c9a-4fe4-a998-55d3f1754f21"   // holderId - same as client in this case
    }
  }
}
```

`SPHERE_ALREADY_HELD` (`s_a_h`) by another client:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "s_a_h",
    "d": {
      "spId": "66b93b19-03d6-4220-b44f-5b33d5d59269",
      "hId": "055fa916-5e99-44dd-9e23-2f9323bb6f4c"
    }
  }
}
```

`CLIENT_HOLDING_MAX_SPHERES` (`c_h_m_s`) - a client can only hold one sphere in each hand:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "c_h_m_s",
    "d": {}
  }
}
```

A successful grab creates a "hold" on the sphere, stopping other clients from acting on the sphere, and lasting until either the client sends a `RELEASE_SPHERE` (`r_s`) message, or is inactive (sends no messages relating to the held sphere) for a period defined in `roomConstants.SPHERE_INFO.SPHERE_HOLD_TIMEOUT`, in the file `src/rooms/room-data-constants.js`, currently set to 10 seconds. If the hold times out, the server sends a `SPHERE_HOLD_TIMEOUT` (`s_h_t`) to the client:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "s_h_t",
    "d": {
      "spId": "922fbed6-5c57-4c14-bf0f-080abf5fa285"
    }
  }
}
```

and a `ROOM_SPHERE_RELEASED` (`r_s_r`) message to any other clients in the room:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "r_s_r",
    "d": {
      "spId": "922fbed6-5c57-4c14-bf0f-080abf5fa285",
      "cId": "1fe992a5-05d9-4791-8482-c598f31dbe22"
    }
  }
}
```


#### `RELEASE_SPHERE` (`r_s`)

```
{
  "t": "r_s",
  "d": {
    "spId": "6f0e19bd-2451-43a0-be9d-b6e208e65ee2"
  }
}
```

On receiving a `RELEASE_SPHERE` message, the server checks that the client is holding it, and if so sends a `RELEASE_SPHERE_SUCCESS` (`r_sp_s`) message to the client:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "r_sp_s",
    "d": {
      "spId": "a62cabcd-e7d9-4687-beac-0c6c7fb9b953"
    }
  }
}
```

and a `ROOM_SPHERE_RELEASED` (`r_s_r`) message to any other clients in the room:

```
{
  "f": "81270b00-2dde-4c33-ab4d-6cfc76b46c0c",
  "m": {
    "t": "r_s_r",
    "d": {
      "spId": "a62cabcd-e7d9-4687-beac-0c6c7fb9b953",
      "cId": "d8f59526-60b6-47ee-ab86-f39f441b8aaa"    // released by this client
    }
  }
}
```

Possible errors are `NON_EXISTENT_SPHERE` (as in `GRAB_SPHERE` above) and `RELEASE_SPHERE_INVALID` (`r_sp_i`):

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "r_sp_i",
    "d": {
      "spId": "9c674bab-e2a6-4588-beff-2342d10a97b0"
    }
  }
}
```


#### `DELETE_SPHERE` (`d_s`)

```
{
  "t": "d_s",
  "d": {
    "spId": "cfa16485-51e5-4db0-b54a-1cab6b6f003f"
  }
}
```

On successful delete the server sends `DELETE_SPHERE_SUCCESS` (`d_s_s`):

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "d_s_s",
    "d": {
      "spId": "cfa16485-51e5-4db0-b54a-1cab6b6f003f"
    }
  }
}
```

and notifies any other clients with a `ROOM_SPHERE_DELETED` (`r_s_d`) message:

```
{
  "f": "5e44fd4e-54cb-42d7-ac41-f381a3844fee",
  "m": {
    "t": "r_s_d",
    "d": {
      "spId": "9de12a8d-96a4-42e8-842e-4230db33b306",
      "cId": "5e44fd4e-54cb-42d7-ac41-f381a3844fee"
    }
  }
}
```

Possible errors include `NON_EXISTENT_SPHERE` (as above), `DELETE_SPHERE_DENIED` (`d_s_d`) if another client is holding the sphere:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "d_s_d",
    "d": {
      "spId": "9de12a8d-96a4-42e8-842e-4230db33b306",
      "hId": "9847a6ac-ae7c-43a8-8599-83b3079c9f1a"
    }
  }
}
```

#### `STRIKE_SPHERE` (`s_s`)

```
{
  "t": "s_s",
  "d": {
    "spId": "aabb5ecd-1662-4db5-91f9-6bd1d8f2b0f9",
    "v": 0.5
  }
}
```
Sphere strikes are broadcast to other clients in the room via a `ROOM_SPHERE_STRUCK` (`r_s_s`) message: 

```
{
  "f": "1a3823f3-79e6-4cd1-9e33-2044d16da300",
  "m": {
    "t": "r_s_s",
    "d": {
      "spId": "aabb5ecd-1662-4db5-91f9-6bd1d8f2b0f9",
      "v": 0.5,
      "cId": "1a3823f3-79e6-4cd1-9e33-2044d16da300"
    }
  }
}
```

Strikes sent to non-existent spheres are silently dropped.


#### `SET_SPHERE_TONE` (`s_s_t`)


```
{
  "t": "s_s_t",
  "d": {
    "spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33",
    "t": 10	// tone
  }
}
```

On successful set, the server sends a `SET_SPHERE_TONE_SUCCESS` (`s_s_t_s`) message:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "s_s_t_s",
    "d": {
      "spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33"
    }
  }
}
```

and notifies other clients of the change in tone with a `ROOM_SPHERE_TONE_SET` (`r_s_t_s`) message:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "r_s_t_s",
    "d": {
      "spId": "cd3aa6cf-f15c-4d7a-be6e-d862912fbd62",
      "t": 10,  // tone
      "cId": "13eb8894-330a-4e37-8ce3-645edf3c6773"
    }
  }
}
```

Clients don't need to hold a sphere to set its tone, but if it's currently held by another client, the server will send a `SET_SPHERE_TONE_DENIED` (`s_s_t_d`) message:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "s_s_t_d",
    "d": {
      "spId": "99c2989c-beba-4c3f-acb9-6f5fe5e4ec33",
      "hId": "9e8e4e40-a523-4345-8e21-b274177fe073"
    }
  }
}
```

As above, the server will send a `NON_EXISTENT_SPHERE` message if the specified sphere doesn't exist in the room.

#### `SET_SPHERE_CONNECTIONS` (`s_s_c`)

```
{
  "t": "s_s_c",
  "d": {
    "spId": "973c742d-866f-4b25-9ae6-8830e7f13e59",
    "c": [	// connections
      "221c3e00-c0e9-4bd9-bdb3-187c5dcf5499",
      "03d6c324-73e0-4804-9b84-9b203a303a38"
    ]
  }
}
```

As in `SET_SPHERE_TONE`, clients don't need to hold a sphere to set its connections, but if it's currently held by another client, the server will send a `SET_SPHERE_CONNECTIONS_DENIED` (`s_s_c_d`) message:

```
{
  "f": "d1e313ee-9eac-47e5-9b9f-5daee88a8057",
  "m": {
    "t": "s_s_c_d",
    "d": {
      "spId": "973c742d-866f-4b25-9ae6-8830e7f13e59",
      "hId": "7bf0b7a2-ee3e-4b76-9d68-675aa4e97f2a"
    }
  }
}
```

As above, the server will send a `NON_EXISTENT_SPHERE` message if the specified sphere doesn't exist in the room.




================================================
FILE: backend/package.json
================================================
{
  "name": "webvr-musicalforest-backend",
  "version": "1.0.0",
  "description": "node.js websocket app server for forest.webvrexperiments.com",
  "main": "index.js",
  "scripts": {
    "start": "ENVIRONMENT_NAME=$(hostname) LOCAL_IP_ADDRESS=$(/sbin/ifconfig en0|grep -w inet|awk '{print $2}') PROJECT_ID=$(gcloud config list project --format json | jq -r .core.project) babel-node src/index.js",
    "build": "babel src -d dist",
    "clean": "rm -rf dist",
    "serve": "NODE_ENV=production ENVIRONMENT_NAME=$(hostname) LOCAL_IP_ADDRESS=$(/sbin/ifconfig en0|grep -w inet|awk '{print $2}') PROJECT_ID=$(gcloud config list project --format json | jq -r .core.project) node dist/index.js",
    "emulators": "parallelshell 'gcloud beta emulators datastore start --no-store-on-disk' 'gcloud beta emulators pubsub start'",
    "start_emulated": "$(gcloud beta emulators datastore env-init) && $(gcloud beta emulators pubsub env-init) && NODE_ENV=production npm start"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/googlecreativelab/webvr-musicalforest/"
  },
  "author": "Google Creative Lab",
  "license": "Apache-2.0",
  "devDependencies": {
    "babel-cli": "6.18.0",
    "babel-polyfill": "6.16.0",
    "babel-preset-es2015": "6.18.0",
    "babel-preset-stage-2": "6.18.0",
    "mocha": "3.2.0",
    "nodemon": "1.11.0",
    "parallelshell": "^2.0.0"
  },
  "dependencies": {
    "@google-cloud/datastore": "0.7.0",
    "@google-cloud/logging": "0.7.0",
    "@google-cloud/pubsub": "0.8.0",
    "@google-cloud/storage": "0.7.0",
    "ajv": "4.10.0",
    "arrify": "1.0.1",
    "dateformat": "2.0.0",
    "express": "4.14.0",
    "fast-ratelimit": "2.0.6",
    "grpc": "1.0.1",
    "hashring": "3.2.0",
    "hashtable": "2.0.1",
    "http-proxy": "1.16.2",
    "javascript-state-machine": "2.4.0",
    "node-fetch": "1.6.3",
    "object-sizeof": "1.1.1",
    "redux": "3.6.0",
    "redux-actions": "1.1.0",
    "redux-saga": "0.14.3",
    "tracer": "0.8.7",
    "uuid": "3.0.1",
    "uws": "0.14.1"
  }
}


================================================
FILE: backend/src/config.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import fs           from 'fs';
import uuid         from 'uuid';
import dateformat   from 'dateformat';
import storage      from '@google-cloud/storage';

import messageConstants     from './messages/message-constants';
import serverConstants      from './server/server-constants';

import { formatStringAsGcpResourceName } from './utils/string-utils';

const logError = ( errorMsg ) => {
    let ts = dateformat( new Date(), LOG_DATE_FORMAT );
	console.error( `[${SHORT_SERVER_ID}|${ts}] => `, errorMsg );
};


/*******************************************************************************
* REQUIRED ENVIRONMENT VARIABLES
*******************************************************************************/

// nothing will work without a GCP PROJECT_ID
if( !process.env.PROJECT_ID ) {
    logError( `PROJECT_ID not specified, exiting.` );
	process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}

const PROJECT_ID = process.env.PROJECT_ID;


// nothing will work without a LOCAL_IP_ADDRESS
if( !process.env.LOCAL_IP_ADDRESS ) {
    logError( `LOCAL_IP_ADDRESS not specified, exiting.` );
	process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
}

const LOCAL_IP_ADDRESS = process.env.LOCAL_IP_ADDRESS;

// nothing will work without an ENVIRONMENT_NAME

if( !process.env.ENVIRONMENT_NAME ) {
    logError( `ENVIRONMENT_NAME not specified, exiting.` );
	process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
};

const ENVIRONMENT_NAME = process.env.ENVIRONMENT_NAME;


/*******************************************************************************
* LOG FORMAT INFO
*******************************************************************************/

// generate a server id
const SERVER_ID             = uuid();
const SHORT_SERVER_ID       = SERVER_ID.split( '-' ).slice(0, 2).join( '-' );
const LOG_DATE_FORMAT       = "yyyymmdd|HH:MM:ss.l";

/*******************************************************************************
* PUBSUB SYNC CHANNEL INFO
*******************************************************************************/

// setup PubSub sync channel topic name, distinct per execution group
// e.g. 'gce', 'gke', 'mymac.config'
const syncTopicNameComponents = [
    serverConstants.SYNC_INFO.SYNC_TOPIC_NAME,
    '-',
    ENVIRONMENT_NAME
];

const SYNC_TOPIC_NAME = formatStringAsGcpResourceName(
    syncTopicNameComponents.join( '' )
);


// only log to Stackdriver explicitly if requested
const LOG_TO_CLOUD          = process.env.LOG_TO_CLOUD === 'true' ? true : false;

/*******************************************************************************
* NETWORK IP/PORT INFO
*******************************************************************************/

// set up some defaults
const WS_SERVER_PORT        = process.env.WS_SERVER_PORT        ? Number( process.env.WS_SERVER_PORT ) : 8100;
const WS_BALANCER_PORT      = process.env.WS_BALANCER_PORT      ? Number( process.env.WS_BALANCER_PORT ) : 9100;
const MAX_CLIENTS_PER_ROOM  = process.env.MAX_CLIENTS_PER_ROOM  ? Number( process.env.MAX_CLIENTS_PER_ROOM ) : 10;

// construct balancer identifier
const LOCAL_IP_PORT_STRING  = `${LOCAL_IP_ADDRESS}:${WS_SERVER_PORT}`;


/*******************************************************************************
* SSL INFO FOR HOSTING ON GKE
*******************************************************************************/

// GKE deploys provide USE_SSL=true in the environment
const USE_SSL = process.env.USE_SSL === "true" ? true : false;

// tell the app whether to serve SSL
let SSL_INFO = {
    useSsl: USE_SSL
};

// if so, set up the certificate info
if( USE_SSL === true ) {

    // make sure there's a specified certificate name
    if( !process.env.SSL_CERT_HOST_NAME ) {
        logError( `USE_SSL=true requires a specified SSL_CERT_HOST_NAME` );
        process.exit( serverConstants.ERROR_TYPES.BAD_OS_EXIT_ERROR_CODE );
    }

    // prepare for export, set up GCS bucket/file info
    SSL_INFO.sslCertHostName        = process.env.SSL_CERT_HOST_NAME;
    SSL_INFO.sslStorageBucketName   = `${PROJECT_ID}-ssl`;
    SSL_INFO.privKeyFileName        = `${process.env.SSL_CERT_HOST_NAME}/privkey.pem`;
    SSL_INFO.fullChainFileName      = `${process.env.SSL_CERT_HOST_NAME}/fullchain.pem`;

}

/*******************************************************************************
* ROOM CHOICE THRESHOLD CONFIGS
*******************************************************************************/

// provide shortcuts for headset type constants
const HT_3DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
const HT_6DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
const HT_VIEWER = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;


// set up headset threshold rules
const HEADSET_RULES = {
    [ HT_3DOF ]: {
        threshold:  3
    },
    [ HT_6DOF ]: {
        threshold:  3
    },
    [ HT_VIEWER ]: {
        threshold:  10
    }
};


/*******************************************************************************
* CHOICE OF WHETHER TO TIMEOUT SPHERE HOLDS AUTOMATICALLY
*******************************************************************************/

const TIMEOUT_SPHERE_HOLDS = process.env.TIMEOUT_SPHERE_HOLDS === 'false' ? false : true;;

/*******************************************************************************
* CHOICE OF WHETHER TO DROP VIEWER MESSAGES
*******************************************************************************/

const DROP_VIEWER_MESSAGES_IN_PRODUCTION = process.env.DROP_VIEWER_MESSAGES_IN_PRODUCTION === 'true' ? true : false;

/*******************************************************************************
* CHOICE OF WHETHER TO RATE LIMIT CLIENT/SPHERE POSITION UPDATES
*******************************************************************************/

const PER_CLIENT_RATE_LIMIT         = process.env.PER_CLIENT_RATE_LIMIT === 'false' ? false : true;
const PER_MESSAGE_TYPE_RATE_LIMIT   = process.env.PER_MESSAGE_TYPE_RATE_LIMIT === 'false' ? false : true;

const RATE_LIMIT_INFO   = {
    perClientRateLimit:         PER_CLIENT_RATE_LIMIT,          // switch per-clientrate limiting on or off
    perMessageTypeRateLimit:    PER_MESSAGE_TYPE_RATE_LIMIT,    // switch rate limiting on or off

    perTypeMsgThreshold:    1,      // 1 message of each type per client
    perTypeMsgTtl:          0.2,    // per 200ms

    perClientMsgThreshold:  20,     // 20 message of any type per client
    perClientMsgTtl:        1       // per 1s
};

/*******************************************************************************
* NAME OF PRODUCTION ENVIRONMENT GCP PROJECT
*******************************************************************************/

const PRODUCTION_ENVIRONMENT_PROJECT_ID         = '<your-production-project-id>';
const PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN    = '<your-production-frontend-hostname>';

/*******************************************************************************
* EXPORT CONFIG
*******************************************************************************/

export default {
    environmentName:                        ENVIRONMENT_NAME,
    syncTopicName:                          SYNC_TOPIC_NAME,
    logDateFormat:                          LOG_DATE_FORMAT,
    logToCloud:                             LOG_TO_CLOUD,
    localIpAddress:                         LOCAL_IP_ADDRESS,
    localIpPortString:                      LOCAL_IP_PORT_STRING,
    serverPort:                             WS_SERVER_PORT,
    balancerPort:                           WS_BALANCER_PORT,
    projectId:                              PROJECT_ID,
    dropViewerMessagesInProduction:         DROP_VIEWER_MESSAGES_IN_PRODUCTION,
    productionEnvironmentProjectId:         PRODUCTION_ENVIRONMENT_PROJECT_ID,
    productionEnvironmentRequiredOrigin:    PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN,
    maxClientsPerRoom:                      MAX_CLIENTS_PER_ROOM,
    clientRoomJoinDeadlineInMs:             5000,
    roomHeartbeatDelayInMs:                 5000,
    serverId:                               SERVER_ID,
    shortServerId:                          SHORT_SERVER_ID,
    sslInfo:                                SSL_INFO,
    headsetRules:                           HEADSET_RULES,
    rateLimitInfo:                          RATE_LIMIT_INFO,
    timeoutSphereHolds:                     TIMEOUT_SPHERE_HOLDS
};


================================================
FILE: backend/src/datastore/datastore-constants.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

const DATASTORE = {
    // entity types
    DS_ENTITY_KEY_SERVER_SUBSCRIPTION_INFO: 'ServerSubscriptionInfo',
    DS_ENTITY_KEY_PER_ENVIRONMENT_RATE_LIMIT_INFO: 'PerEnvironmentRateLimitInfo'
};

export default DATASTORE;


================================================
FILE: backend/src/datastore/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import datastore                from '@google-cloud/datastore';
import config                   from '../config';

import { logger }               from '../logger';

import datastoreConstants       from './datastore-constants';

// create a module-wrapped datastore client object at startup
let dsClient = ( ( conf ) => {

    if( !dsClient ) {
        if( process.env.DATASTORE_EMULATOR_HOST ) {
            logger.info( `using Datastore emulator running at ${process.env.DATASTORE_EMULATOR_HOST}` );
        }
        else {
            logger.info( `using live Datastore on GCP` );
        }

        dsClient = datastore( conf );
    }

    return dsClient;

})( config );

const runQuery = function* ( query ) {
    return yield dsClient.runQuery( query );
};

const save = function* ( entities ) {
    return yield dsClient.save( entities );
};

const key = function ( keyComponents ) {
    return dsClient.key( keyComponents );
};

// export functions to the default namespace, to make a 'datastore' import for other modules
export default {
    runQuery,
    save,
    key
};

// export constants individually, to make a { datastoreConstants } import for other modules
export {
    datastoreConstants,
    dsClient
}


================================================
FILE: backend/src/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import "babel-polyfill";

import config                   from './config';

import { dispatchStoreAction }  from './store';
import { logger }               from './logger';
import { serverActions }        from './server';

// create a server setup request action
let setupAction = serverActions.startServerSetupRequestAction();

/*
 * dispatch the setup request to the store.
 * all the action now happens via setupServer() in
 * server/server-sagas.js
 */
dispatchStoreAction( setupAction );


================================================
FILE: backend/src/logger/cloud-logger.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import Logging from '@google-cloud/logging';

import config from '../config';

import { formatStringAsGcpResourceName } from '../utils/string-utils';

// create a client
const loggingClient = Logging( config );

// the name of the log to write to
const logName = formatStringAsGcpResourceName(
    `${config.environmentName}-cloud-app-log`
);

// choose a log to write to
const selectedLog = loggingClient.log( logName );

// set up resource metadata
const metadata = {
    resource: {
        type: 'project',
        labels: {
            project_id: config.projectId
        }
    }
};

// log function wrapper
const writeCloudLogEntry = ( tracerData ) => {

    const entry = selectedLog.entry( metadata, tracerData.output );

    selectedLog.write(entry).catch(
        ( error ) => {
            console.error( `error logging message: ${error.message}` );
        }
    );

};

export {
    writeCloudLogEntry
};


================================================
FILE: backend/src/logger/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import tracer from 'tracer';

import config from '../config';

import { writeCloudLogEntry } from './cloud-logger';

let selectedConsole = config.logToCloud ? tracer.console : tracer.colorConsole;

let consoleOptions = {
    format:     "[{{shortServerId}}|{{timestamp}}] {{sigil}} {{message}} [{{file}}:{{line}}]",
    dateformat: config.logDateFormat,
    preprocess: (data) => {
        data.shortServerId = config.shortServerId;
        data.localIpAddress = config.localIpAddress;
        data.sigil = {
            error:  `=>`,
            warn:   `**`,
            info:   `->`,
            debug:  `||`,
            trace:  `##`,
            log:    ``
        }[ data.title ];
    }
};

// only do info() and above in production
if( config.projectId === config.productionEnvironmentProjectId ) {
    consoleOptions.level    = 'info';
    consoleOptions.format   = "[{{shortServerId}}|{{localIpAddress}}|{{timestamp}}] {{sigil}} {{message}} [{{file}}:{{line}}]";
}

// write log via Stackdriver API?
if( config.logToCloud ) {
    consoleOptions.transport = writeCloudLogEntry;
}

let l = selectedConsole( consoleOptions );

let logger = {
    error:  l.error,
    warn:   l.warn,
    info:   l.info,
    debug:  l.debug,
    trace:  l.trace,
    log:    l.log
};

export {
    logger
};


================================================
FILE: backend/src/messages/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import messageActions   from './message-actions';
import messageConstants from './message-constants';
import messageHandler   from './message-handler';
import messageSagas     from './message-sagas';
import messageValidator from './message-validator';

export {
    messageActions,
    messageConstants,
    messageHandler,
    messageSagas,
    messageValidator
};


================================================
FILE: backend/src/messages/message-actions.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import messageConstants     from './message-constants';
import { messageSchema }    from './message-schema';

/*******************************************************************************
*
* reference of data structure from JSON schema
*
*   {
*      definitions: {
*        ...,
*        room_client_position_update:
*         { type: 'object',
*           properties: [Object],
*           required: [Object] }
*     },
*
*     properties: {
*        ...,
*        room_client_position_update:
*         { '$ref': '#/definitions/room_client_position_update' }
*     }
*   }
*
*   room_client_position_update:  { type: { type: 'string', minLength: 29, maxLength: 29 },
*     data:
*      { type: 'object',
*        properties:
*         { head: [Object],
*           left: [Object],
*           right: [Object],
*           userdata: [Object] },
*        required: [ 'head', 'left', 'right' ] } }
*
********************************************************************************/

/*******************************************************************************
* see https://github.com/acdlite/redux-actions
*
* redux-actions generates action request objects for use as triggers in redux.
*
* example:
* {
*   type: 'INCREMENT',
*   payload: 42,
*   otherVar: true
* }
*
*******************************************************************************/

// generates 'redux-actions' action functions for all incoming message types in schema
const messageRequestActions = Object.keys( messageSchema.properties ).reduce(

    // for each key in messageSchema.properties
    ( acc, type ) => {

        // create a function which ...
        let fn = function() {
            // combines the provided type with fn args to generate an action request object
            return Object.assign( { type }, arguments[ 0 ] );
        };

        // key the function by the incoming message type name, to export them all
        acc[ type ] = fn;
        return acc;
    },

    {} // empty starting accumulator
);

// export the generated functions for use as "import messageActions from 'message-actions'"
export default messageRequestActions;


================================================
FILE: backend/src/messages/message-constants.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

const sphereIdLabel     = 'spId';
const clientIdLabel     = 'cId';
const holderIdLabel     = 'hId';
const serverIdLabel     = 'srId';
const toneLabel         = 't';
const positionLabel     = 'p';
const rotationLabel     = 'r';
const headLabel         = 'h';
const leftLabel         = 'l';
const rightLabel        = 'r';
const sphereLabel       = 's';
const headsetTypeLabel  = 'ht';
const velocityLabel     = 'v';
const connectionsLabel  = 'c';
const meristemLabel     = 'm';

const fromLabel         = 'f';
const typeLabel         = 't';
const dataLabel         = 'd';
const msgLabel          = 'm';

const roomNameLabel     = 'rn';
const soundbankLabel    = 'sb';
const clientsLabel      = 'c';
const spheresLabel      = 's';

const INCOMING_MESSAGE_TYPES = {

    EXIT_ROOM:                          'e_r',

    UPDATE_CLIENT_COORDS:               'u_c_c',    // position msg for individual client

    CREATE_SPHERE_OF_TONE_AT_POSITION:  'c_s_o_t_a_p',
    GRAB_SPHERE:                        'g_s',
    RELEASE_SPHERE:                     'r_s',
    DELETE_SPHERE:                      'd_s',
    STRIKE_SPHERE:                      's_s',
    SET_SPHERE_TONE:                    's_s_t',
    SET_SPHERE_CONNECTIONS:             's_s_c'
};

const INCOMING_MESSAGE_COMPONENTS = {
    ALL_MESSAGES: {
        FROM:   fromLabel,
        MSG:    msgLabel,
        TYPE:   typeLabel,
        DATA:   dataLabel
    },
    REFERENCES: {
        COORDINATE:     'coordinate',
        COORDINATE_SET: 'coordinateSet'
    },
    REF_COORDINATE: {
        X:  'x',
        Y:  'y',
        Z:  'z'
    },
    REF_COORDINATE_SET: {
        POSITION:   positionLabel,
        ROTATION:   rotationLabel
    },
    UPDATE_CLIENT_COORDS: {
        HEAD:               headLabel,
        LEFT:               leftLabel,
        RIGHT:              rightLabel,
        SPHERES:            spheresLabel,
        SPHERE_ID:          sphereIdLabel,
        SPHERE_POSITION:    positionLabel
    },
    CREATE_SPHERE_OF_TONE_AT_POSITION: {
        TONE:   toneLabel,
        POSITION:   positionLabel
    },
    GRAB_SPHERE: {
        SPHERE_ID:  sphereIdLabel
    },
    RELEASE_SPHERE: {
        SPHERE_ID: sphereIdLabel
    },
    DELETE_SPHERE: {
        SPHERE_ID: sphereIdLabel
    },
    STRIKE_SPHERE: {
        SPHERE_ID:  sphereIdLabel,
        VELOCITY:   velocityLabel
    },
    SET_SPHERE_TONE: {
        SPHERE_ID:  sphereIdLabel,
        TONE:       toneLabel
    },
    SET_SPHERE_CONNECTIONS: {
        SPHERE_ID:      sphereIdLabel,
        CONNECTIONS:    connectionsLabel
    }
};

const OUTGOING_MESSAGE_TYPES = {

    CONNECTION_INFO:                    'c_i',          // sent to new clients on connect
    ROOM_STATUS_INFO:                   'r_s_i',        // sent to new clients on joining a room
    ROOM_EXIT_SUCCESS:                  'r_e_s',        // sent to clients leaving a room

    ROOM_HEARTBEAT:                     'r_h',          // server heartbeat message for a room

    ROOM_CLIENT_JOIN:                   'r_c_j',        // sent to existing clients on client join
    ROOM_CLIENT_EXIT:                   'r_c_e',        // sent to remaining clients on client exit
    ROOM_CLIENT_COORDS_UPDATED:         'r_c_c_u',

    ROOM_SPHERE_CREATED:                'r_s_c',
    ROOM_SPHERE_GRABBED:                'r_s_g',
    ROOM_SPHERE_POSITION_UPDATED:       'r_s_p_u',
    ROOM_SPHERE_TONE_SET:               'r_s_t_s',
    ROOM_SPHERE_CONNECTIONS_SET:        'r_s_c_s',
    ROOM_SPHERE_RELEASED:               'r_s_r',
    ROOM_SPHERE_STRUCK:                 'r_s_s',
    ROOM_SPHERE_DELETED:                'r_s_d',

    CREATE_SPHERE_DENIED:               'c_s_d',
    CREATE_SPHERE_SUCCESS:              'c_s_s',

    GRAB_SPHERE_DENIED:                 'g_s_d',
    GRAB_SPHERE_SUCCESS:                'g_s_s',

    RELEASE_SPHERE_DENIED:              'r_sp_d',
    RELEASE_SPHERE_INVALID:             'r_sp_i',
    RELEASE_SPHERE_SUCCESS:             'r_sp_s',

    DELETE_SPHERE_DENIED:               'd_s_d',
    DELETE_SPHERE_INVALID:              'd_s_i',
    DELETE_SPHERE_SUCCESS:              'd_s_s',

    SET_SPHERE_TONE_DENIED:             's_s_t_d',
    SET_SPHERE_TONE_INVALID:            's_s_t_i',
    SET_SPHERE_TONE_SUCCESS:            's_s_t_s',

    SET_SPHERE_CONNECTIONS_DENIED:      's_s_c_d',
    SET_SPHERE_CONNECTIONS_INVALID:     's_s_c_i',
    SET_SPHERE_CONNECTIONS_SUCCESS:     's_s_c_s',

    CONNECT_SPHERES_IDENTICAL:          'c_s_id',
    CONNECT_SPHERES_INVALID:            'c_s_in',
    CONNECT_SPHERES_MISSING:            'c_s_m'
};

const OUTGOING_MESSAGE_COMPONENTS = {
    ALL_MESSAGES: {
        DATA:   dataLabel,
        FROM:   fromLabel,
        MSG:    msgLabel,
        TYPE:   typeLabel,
        CLIENT_ID:  clientIdLabel
    },
    CONNECTION_INFO: {
        CLIENT_ID:  clientIdLabel,
        SERVER_ID:  serverIdLabel
    },
    ROOM_STATUS_INFO: {
        ROOM_NAME:      roomNameLabel,
        SOUNDBANK:      soundbankLabel,
        CLIENTS:        clientsLabel,
        CLIENT_ID:      clientIdLabel,
        CLIENT_HEADSET_TYPE:    headsetTypeLabel,
        SPHERES:        spheresLabel,
        SPHERE_ID:      sphereIdLabel,
        TONE:           toneLabel,
        POSITION:       positionLabel,
        CONNECTIONS:    connectionsLabel,
        MERISTEM:       meristemLabel
    },
    ROOM_EXIT_SUCCESS: {
        ROOM_NAME:  roomNameLabel
    },
    CREATE_SPHERE_SUCCESS: {
        SPHERE_ID:  sphereIdLabel
    },
    SPHERE_ACTION_DENIED: {
        SPHERE_ID:  sphereIdLabel,
        HOLDER_ID:  holderIdLabel,
    },
    ROOM_SPHERE_CREATED: {
        SPHERE_ID:  sphereIdLabel,
        TONE:       toneLabel,
        POSITION:   positionLabel,
        CLIENT_ID:  clientIdLabel
    },
    GRAB_SPHERE_SUCCESS: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel
    },
    RELEASE_SPHERE_SUCCESS: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel
    },
    SPHERE_HOLD_TIMEOUT: {
        SPHERE_ID:  sphereIdLabel,
    },
    ROOM_SPHERE_RELEASED: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel
    },
    ROOM_CLIENT_COORDS_UPDATED: {
        HEAD:   headLabel,
        LEFT:   leftLabel,
        RIGHT:  rightLabel,
        SPHERES: sphereLabel
    },
    ROOM_SPHERE_POSITION_UPDATED: {
        SPHERE_ID:  sphereIdLabel,
        POSITION:   positionLabel,
        CLIENT_ID:  clientIdLabel
    },
    SET_SPHERE_TONE_SUCCESS: {
        SPHERE_ID:  sphereIdLabel
    },
    ROOM_SPHERE_TONE_SET: {
        SPHERE_ID:  sphereIdLabel,
        TONE:       toneLabel,
        CLIENT_ID:  clientIdLabel
    },
    SET_SPHERE_CONNECTIONS_SUCCESS: {
        SPHERE_ID:  sphereIdLabel
    },
    ROOM_SPHERE_CONNECTIONS_SET: {
        SPHERE_ID:      sphereIdLabel,
        CONNECTIONS:    connectionsLabel,
        CLIENT_ID:      clientIdLabel
    },
    DELETE_SPHERE_SUCCESS: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel
    },
    ROOM_SPHERE_DELETED: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel
    },
    ROOM_SPHERE_STRUCK: {
        SPHERE_ID:  sphereIdLabel,
        CLIENT_ID:  clientIdLabel,
        VELOCITY:   velocityLabel
    },
    ROOM_CLIENT_JOIN: {
        CLIENT_ID:  clientIdLabel,
        CLIENT_HEADSET_TYPE: headsetTypeLabel
    },
    ROOM_CLIENT_EXIT: {
        CLIENT_ID:  clientIdLabel
    },
    ROOM_HEARTBEAT: {
        COUNT:      'c',
        SECONDS:    's'
    }
};

const HEADSET_TYPES = {
    HEADSET_TYPE_3DOF:      '3dof',
    HEADSET_TYPE_6DOF:      '6dof',
    HEADSET_TYPE_VIEWER:    'viewer'
};

const ERROR_TYPES = {
    INVALID_URL:                    'i_u',
    NO_ROOMS_AVAILABLE:             'n_r_a',

    NOT_IN_ROOM:                    'n_i_r',
    NO_SUCH_ROOM:                   'n_s_r',
    ROOM_QUEUE_FULL:                'r_q_f',
    ROOM_FULL:                      'r_f',
    ROOM_UNAVAILABLE:               'r_u',
    BUSY_TRY_AGAIN:                 'b_t_a',
    ROOM_NOT_READY:                 'r_n_r',
    ROOM_JOIN_TIMEOUT:              'r_j_t',
    ALREADY_IN_ROOM:                'a_i_r',
    ALREADY_IN_ROOM_QUEUE:          'a_i_r_q',

    CREATE_SPHERE_UNAVAILABLE:      'c_s_u',
    NON_EXISTENT_SPHERE:            'n_e_s',
    SPHERE_ALREADY_HELD:            's_a_h',
    CLIENT_HOLDING_SPHERE:          'c_h_s',
    CLIENT_HOLDING_MAX_SPHERES:     'c_h_m_s',
    TOO_MANY_SPHERE_CONNECTIONS:    't_m_s_c',

    GRAB_SPHERE_ERROR:              'g_s_e',
    RELEASE_SPHERE_ERROR:           'r_s_e',
    DELETE_SPHERE_ERROR:            'd_s_e',
    CONNECT_SPHERE_ERROR:           'c_s_e',
    SPHERE_HOLD_TIMEOUT:            's_h_t',

    CLIENT_INACTIVITY_TIMEOUT:      'c_i_t',

    SYSTEM_ERROR:                   's_'
};

export default {
    INCOMING_MESSAGE_TYPES,
    INCOMING_MESSAGE_COMPONENTS,
    OUTGOING_MESSAGE_TYPES,
    OUTGOING_MESSAGE_COMPONENTS,
    HEADSET_TYPES,
    ERROR_TYPES
};

export {
    HEADSET_TYPES
};


================================================
FILE: backend/src/messages/message-handler.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {
    roomDataActions,
    roomContentConstants,
    roomDataConstants,
    roomStateConstants
} from '../rooms';

import { logger }                       from '../logger';
import { roomStateActions }             from '../rooms'
import { serialize }                    from '../s11n';
import { sendWsMessageWithLogger }      from '../utils/websocket-utils';

import {
    dispatchStoreAction,
    getStoreState
} from '../store';

import config                           from '../config';

import messageActions                   from './message-actions';
import messageConstants                 from './message-constants';
import { perMessageTypeRateLimiter }    from './message-rate-limiter';
import messageValidator                 from './message-validator';

const messageComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;

/*******************************************************************************
* HANDLER FOR INCOMING ROOM MESSAGES FROM THIS SERVER'S WEBSOCKET CLIENTS
*******************************************************************************/

/*
 * this function validates an incoming message against the JSON message schema,
 * and dispatches successfully validated messages to the appropriate handler
 * defined in messageActions, generated from the message types in the schema.
 */
const handleWebsocketMessage = ( ws, message ) => {

    // drop empty messages
    if( typeof message === 'undefined' || message === '' || message === null ) {
        return;
    }

    // make sure the client's in a room
    if( typeof ws.currentRoom === 'undefined' ) {
        return;
    }

    // make sure message format is valid, drop any invalid messages
    let validatedMessage;

    try {
        validatedMessage = messageValidator.validateMessage( message );
    }
    catch( error ) {
        logger.debug( `error validating incoming message from client ${ws.id}: ${error.message}` );
        return;
    }

    // messageActions exports action functions generated from schema, hence keyed the same
    let validatedMessageType = validatedMessage[ messageComponents.ALL_MESSAGES.TYPE ];

    // if rate limiting's switched off
    if( config.rateLimitInfo.perMessageTypeRateLimit === false ) {
        // handle the message straightaway
        handleValidatedMessageOfTypeForWebsocket( validatedMessage, validatedMessageType, ws );
        return;
    }

    // otherwise, rate-limit messages per type, per client
    const rateLimiterNamespace = `${ws.id}/${validatedMessageType}`;

    perMessageTypeRateLimiter.consume( rateLimiterNamespace )
        // as long as the client is within limits for this message type
        .then(
            () => {
                // handle the message
                handleValidatedMessageOfTypeForWebsocket( validatedMessage, validatedMessageType, ws );
            }
        )
        // otherwise
        .catch(
            ( error ) => {
                // silently drop the message
            }
        );

};

const handleValidatedMessageOfTypeForWebsocket = ( validatedMessage, validatedMessageType, ws ) => {

    // add id to msg, dispatch generated messageAction required by message type
    let attributedMessage = {
        ws,
        [ messageComponents.ALL_MESSAGES.FROM ]:       ws.id,
        [ messageComponents.ALL_MESSAGES.MSG ]:        validatedMessage
    };

    // tell the message saga which room it's in
    attributedMessage.roomName = ws.currentRoom;

    // check the room is in the right state to receive messages
    let roomState   = getStoreState().roomStateReducer.rooms[ ws.currentRoom ];

    // rooms need to be in specific states to accept messages
    let roomStatesAcceptingMessages = [
        roomStateConstants.STATES.READY,
        roomStateConstants.STATES.ROOM_FULL
    ];

    if( roomStatesAcceptingMessages.indexOf( roomState.status ) < 0 ) {

        let roomNotReadyMessage = {
            from: config.serverId,
            msg: {
                type: messageConstants.ERROR_TYPES.ROOM_NOT_READY
            }
        };

        sendWsMessageWithLogger( ws, roomNotReadyMessage, logger );

        return;
    }

    dispatchStoreAction( messageActions[ validatedMessageType ]( attributedMessage ) );

};

export default {
    handleWebsocketMessage
};


================================================
FILE: backend/src/messages/message-rate-limiter.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { FastRateLimit }    from 'fast-ratelimit';

import config               from '../config';

import { logger }           from '../logger';

// rate-limits overall messages per client, regardless of type
let perClientRateLimiter = new FastRateLimit({
    threshold:  config.rateLimitInfo.perClientMsgThreshold,
    ttl:        config.rateLimitInfo.perClientMsgTtl
});

if( config.rateLimitInfo.perClientRateLimit ) {
    logger.info(
        `default overall client message rate limiter allowing ${perClientRateLimiter.__options.threshold}` +
        `msg/${perClientRateLimiter.__options.ttl_millisec}ms`
    );
}

// rate-limits individual message types per-client
let perMessageTypeRateLimiter = new FastRateLimit({
    threshold:  config.rateLimitInfo.perTypeMsgThreshold,
    ttl:        config.rateLimitInfo.perTypeMsgTtl
});

if( config.rateLimitInfo.perMessageTypeRateLimit ) {
    logger.info(
        `default per-type client message rate limiter allowing ${perMessageTypeRateLimiter.__options.threshold}` +
        `msg/${perMessageTypeRateLimiter.__options.ttl_millisec}ms`
    );
}

// allows dynamic setting of per-client rate limiter threshold
const setPerClientRateLimiterOptions = ( options ) => {
    perClientRateLimiter.__options = options;
    logger.info(
        `set overall client message rate limiter to allow ${perClientRateLimiter.__options.threshold}` +
        `msg/${perClientRateLimiter.__options.ttl_millisec}ms`
    );
};

// allows dynamic setting of per-message-type rate limiter threshold
const setPerMessageTypeRateLimiterOptions = ( options ) => {
    perMessageTypeRateLimiter.__options = options;
    logger.info(
        `set per-type client message rate limiter to allow ${perMessageTypeRateLimiter.__options.threshold}` +
        `msg/${perMessageTypeRateLimiter.__options.ttl_millisec}ms`
    );
};

export {
    perClientRateLimiter,
    perMessageTypeRateLimiter,
    setPerClientRateLimiterOptions,
    setPerMessageTypeRateLimiterOptions
};


================================================
FILE: backend/src/messages/message-sagas.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import uuid from 'uuid';

import { delay, takeEvery, takeLatest } from 'redux-saga';
import { call, put, fork, select }      from 'redux-saga/effects';

import { logger }                       from '../logger';
import { sphereConstants }          from '../spheres';

import {
    roomDataActions,
    roomNames,
    roomStateActions,
    roomStateConstants
}  from '../rooms';

import {
    makeWsReplyMessage,
    makeWsBroadcastMessage,
    sendWsMessageWithLogger
} from '../utils/websocket-utils';

import config                       from '../config';
import { sagaUtils }                from '../utils';

import messageActions               from './message-actions';
import messageConstants             from './message-constants';

const incomingMsgComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;
const outgoingMsgComponents = messageConstants.OUTGOING_MESSAGE_COMPONENTS;

/*******************************************************************************
* EXIT ROOM
*******************************************************************************/

const exitRoom = function* ( message ) {

    logger.trace( `client ${message.ws.id} is leaving room ${message.ws.currentRoom}` );

    // send a goodbye message to the client
    let roomExitSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_EXIT_SUCCESS,
        { [ outgoingMsgComponents.ROOM_EXIT_SUCCESS.ROOM_NAME ]: message.ws.currentRoom }
    );

    sendWsMessageWithLogger( message.ws, roomExitSuccessMessage, logger );

    // remove it from the state
    yield put(
        roomDataActions.removeLocalClientFromRoomRequestAction(
            message.ws.id,
            message.ws.currentRoom
        )
    );

    // close the connection
    message.ws.close();
};

/*******************************************************************************
* UPDATE ROOM CLIENT POSITION
*******************************************************************************/

const updateClientCoords = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // check whether the coords include any sphere position update
    let spherePositionUpdates = msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ];

    if( typeof spherePositionUpdates !== 'undefined' ) {

        let roomState = yield select( ( state ) => { return state.roomDataReducer.rooms; } );
        let spheresHeldByClient = roomState[ action.roomName ].content.clients[ action.ws.id ].spheresHeld;
        let checkedSpherePositionUpdates = [];

        // spherePositionUpdates will be an array if it exists, as it's been validated
        const spherePositionUpdateCount = spherePositionUpdates.length;

        for( let i = 0; i < spherePositionUpdateCount; i++ ) {

            let updatedSphere   = spherePositionUpdates[ i ];
            let updatedSphereId = updatedSphere[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_ID ];

            // check that this client is holding this sphere
            if( typeof spheresHeldByClient[ updatedSphereId ] === 'undefined' ) {
                continue;
            }

            let updatedSpherePosition = updatedSphere[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION ];

            if( spheresHeldByClient[ updatedSphereId ] === true ) {

                // set the sphere position in state so new clients see it
                yield put(
                    roomDataActions.setPositionForSphereInRoomRequestAction(
                        updatedSpherePosition,
                        updatedSphereId,
                        action.roomName
                    )
                );

                // add it to the list of checked updates
                checkedSpherePositionUpdates.push( updatedSphere );
            }
        }

        // set a populated checked array back in the message
        if( checkedSpherePositionUpdates.length > 0 ) {
            msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ] = checkedSpherePositionUpdates;
        }

        // or just remove an empty one (= no valid sphere updates)
        else {
            delete msgData[ incomingMsgComponents.UPDATE_CLIENT_COORDS.SPHERES ];
        }
    }

    // don't record any client state change, just broadcast its new position to other clients
    let updatedCoordsMessage = makeWsBroadcastMessage(
        action[ incomingMsgComponents.ALL_MESSAGES.FROM ],
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_CLIENT_COORDS_UPDATED,
        action[ incomingMsgComponents.ALL_MESSAGES.MSG ][ incomingMsgComponents.ALL_MESSAGES.DATA ]
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( updatedCoordsMessage, action.roomName ) );

};

/*******************************************************************************
* CREATE SPHERE AT POSITION IN ROOM
*******************************************************************************/

const createSphereOfToneAtPosition = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    let roomData = yield select( ( state ) => { return state.roomDataReducer; } );
    let sphereState = roomData.rooms[ action.roomName ].content.spheres;

    // make sure there's sphere space in the room
    if( Object.keys( sphereState ).length >= sphereConstants.SPHERE_INFO.MAX_NUMBER_OF_SPHERES_PER_ROOM ) {
        denySphereAction(
            action.ws,
            null,   // no sphere under discussion, so ...
            null,   // ... no client holding it
            messageConstants.ERROR_TYPES.CREATE_SPHERE_UNAVAILABLE
        );
        return;
    }

    // snag relevant data
    let newSphereId = uuid();
    let tone        = msgData[ incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE ];
    let position    = msgData[ incomingMsgComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION ];
    let clientId    = action.ws.id;

    // create the sphere, save in sate
    yield put(
        roomDataActions.addSphereOfToneAtPositionInRoomRequestAction(
            newSphereId,
            tone,
            position,
            action.roomName
        )
    );

    // tell the client it created the sphere OK
    let createSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.CREATE_SPHERE_SUCCESS,
        { [ outgoingMsgComponents.CREATE_SPHERE_SUCCESS.SPHERE_ID ]: newSphereId }
    );

    sendWsMessageWithLogger( action.ws, createSuccessMessage, logger );

    // broadcast 'sphere created' action
    let data = {
        [ outgoingMsgComponents.ROOM_SPHERE_CREATED.SPHERE_ID ]:    newSphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_CREATED.TONE ]:         tone,
        [ outgoingMsgComponents.ROOM_SPHERE_CREATED.POSITION ]:     position,
        [ outgoingMsgComponents.ROOM_SPHERE_CREATED.CLIENT_ID ]:    clientId
    };

    let sphereMsg = makeWsBroadcastMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_CREATED,
        data
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereMsg, action.roomName ) );
};

/*******************************************************************************
* GRAB SPHERE BY ID
*******************************************************************************/

const grabSphere = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let roomState = yield select(
        ( state ) => {
            return state.roomDataReducer.rooms[ action.roomName ];
        }
    );

    // check the client has a spare hand
    let spheresHeldByClient = roomState.content.clients[ action.ws.id ].spheresHeld;

    // if it's already holding more than 1 ...
    if( Object.keys( spheresHeldByClient ).length > 1 ) {

        // it can't grab any more
        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.CLIENT_HOLDING_MAX_SPHERES
        );
        return;
    }

    // snag relevant data
    let sphereId = msgData[ incomingMsgComponents.GRAB_SPHERE.SPHERE_ID ];
    let sphereState = roomState.content.spheres;

    // does sphere exist?
    if( !sphereState[ sphereId ] ) {
        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
        );
        return;

    };

    // is it already held ...
    if( sphereState[ sphereId ].hold ) {
        let alreadyHeldErrorCode;
        let holderId = sphereState[ sphereId ].hold.clientId;

        // ... by this client?
        if( holderId === action.ws.id ) {
            alreadyHeldErrorCode = messageConstants.ERROR_TYPES.CLIENT_HOLDING_SPHERE;
        }

        // or another client?
        else {
            alreadyHeldErrorCode = messageConstants.ERROR_TYPES.SPHERE_ALREADY_HELD;
        }

        // either way, no go
        denySphereAction(
            action.ws,
            sphereId,
            holderId,    // it would be invalid for this ws to grab it
            alreadyHeldErrorCode
        );

        return;
    }

    // create a hold on this sphere for this client
    yield put(
        roomDataActions.createHoldOnSphereForClientInRoomRequestAction(
            sphereId,
            action.ws.id,
            action.roomName
        )
    );

    // TODO check the client got the hold?

    // tell the client it got a hold
    let grabSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.GRAB_SPHERE_SUCCESS,
        { [ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
    );

    sendWsMessageWithLogger( action.ws, grabSuccessMessage, logger );

    // broadcast 'sphere grabbed' action
    let sphereGrabbedData = {
        [ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.SPHERE_ID ]: sphereId,
        [ outgoingMsgComponents.GRAB_SPHERE_SUCCESS.CLIENT_ID ]: action.ws.id
    };

    let sphereGrabbedMsg = makeWsBroadcastMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_GRABBED,
        sphereGrabbedData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereGrabbedMsg, action.roomName ) );

};

/*******************************************************************************
* RELEASE SPHERE BY ID
*******************************************************************************/

const releaseSphere = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let sphereState = yield getSphereStateForRoom( action.roomName );

    // snag relevant data
    let sphereId = msgData[ incomingMsgComponents.RELEASE_SPHERE.SPHERE_ID ];

    // does this sphere exist?
    if( !sphereState[ sphereId ] ) {

        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
        );

        return;
    };

    // is it held at all?
    if( !sphereState[ sphereId ].hold ) {

        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to release it
            messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_INVALID
        );

        return;
    }

    let holderId = sphereState[ sphereId ].hold.clientId;

    if( typeof holderId === 'undefined' ) {

        denySphereAction(
            action.ws,
            sphereId,
            null,
            messageConstants.ERROR_TYPES.SYSTEM_ERROR
        );

        return;
    }

    // is it held by another client?
    if( holderId !== action.ws.id ) {

        denySphereAction(
            action.ws,
            sphereId,
            holderId,  // denied because this other one's holding it
            messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_DENIED
        );

        return;
    }

    // remove the hold on this sphere for this client
    yield put(
        roomDataActions.removeHoldOnSphereForClientInRoomRequestAction(
            sphereId,
            action.ws.id,
            action.roomName
        )
    );

    // tell the client it released the sphere OK
    let releaseSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.RELEASE_SPHERE_SUCCESS,
        { [ outgoingMsgComponents.RELEASE_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
    );

    sendWsMessageWithLogger( action.ws, releaseSuccessMessage, logger );

    // broadcast to the room that the sphere's been released
    let outgoingMsgData = {
        [ outgoingMsgComponents.ROOM_SPHERE_RELEASED.SPHERE_ID ]: sphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_RELEASED.CLIENT_ID ]: action.ws.id
    };

    let sphereReleasedMessage = makeWsBroadcastMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_RELEASED,
        outgoingMsgData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereReleasedMessage, action.roomName ) );
};

/*******************************************************************************
* SET SPHERE TONE BY ID
*******************************************************************************/

const setSphereTone = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let sphereState = yield getSphereStateForRoom( action.roomName );

    // snag relevant data
    let sphereId    = msgData[ incomingMsgComponents.SET_SPHERE_TONE.SPHERE_ID ];
    let tone        = msgData[ incomingMsgComponents.SET_SPHERE_TONE.TONE ];

    // does this sphere exist?
    if( !sphereState[ sphereId ] ) {

        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
        );

        return;
    };

    // is it held by another client?
    if( sphereState[ sphereId ].hold ) {

        let holderId = sphereState[ sphereId ].hold.clientId;

        if( typeof holderId === 'undefined' ) {
            denySphereAction(
                action.ws,
                sphereId,
                null,
                messageConstants.ERROR_TYPES.SYSTEM_ERROR
            );

            return;
        }

        if( holderId !== action.ws.id ) {

            denySphereAction(
                action.ws,
                sphereId,
                holderId,  // denied because this other one's holding it
                messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_TONE_DENIED
            );

            return;
        }
    }

    // set the tone for this sphere
    yield put(
        roomDataActions.setToneForSphereInRoomRequestAction(
            tone,
            sphereId,
            action.roomName
        )
    );

    // tell the client it set the tone OK
    let toneSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_TONE_SUCCESS,
        { [ outgoingMsgComponents.SET_SPHERE_TONE_SUCCESS.SPHERE_ID ]: sphereId }
    );

    sendWsMessageWithLogger( action.ws, toneSuccessMessage, logger );

    // broadcast the new tone to the room
    let outgoingMsgData = {
        [ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.SPHERE_ID ]:   sphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.TONE ]:        tone,
        [ outgoingMsgComponents.ROOM_SPHERE_TONE_SET.CLIENT_ID ]:   action.ws.id
    };

    let sphereToneMsg = makeWsBroadcastMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_TONE_SET,
        outgoingMsgData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereToneMsg, action.roomName ) );

};

/*******************************************************************************
* SET SPHERE CONNECTIONS BY ID
*******************************************************************************/

const setSphereConnections = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let sphereState = yield getSphereStateForRoom( action.roomName );

    // snag relevant data
    let sphereId    = msgData[ incomingMsgComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID ];
    let connections = msgData[ incomingMsgComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS ];

    // make sure there aren't too many connections
    if( connections.length > sphereConstants.SPHERE_INFO.MAX_NUMBER_OF_CONNECTIONS_PER_SPHERE ) {
        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.TOO_MANY_SPHERE_CONNECTIONS
        );
        return;
    }

    // does sphere exist?
    if( !sphereState[ sphereId ] ) {
        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
        );
        return;

    };

    // is it already held ...
    if( sphereState[ sphereId ].hold ) {

        let holderId = sphereState[ sphereId ].hold.clientId;

        // ... by another client?
        if( holderId !== action.ws.id ) {

            denySphereAction(
                action.ws,
                sphereId,
                holderId,    // it would be invalid for this ws to grab it
                messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_DENIED
            );

            return;
        }
    }

    // if it's a non-empty list of spheres to connect to
    if( connections.length > 0 ) {

        // make sure that ...
        let uniqueAvailableConnections = connections.filter(
            // spheres are not the same
            ( sphereIdIterator ) => {
                return sphereIdIterator !== sphereId;
            }
        ).filter(
            // spheres exist in room
            ( sphereIdIterator ) => {
                return Object.keys( sphereState ).indexOf( sphereIdIterator ) >= 0;
            }
        );

        if( uniqueAvailableConnections.length == 0 ) {
            logger.trace( `setSphereConnections: no unique available connections requested` );
            denySphereAction(
                action.ws,
                sphereId,
                null,    // it would be invalid for this ws to set it
                messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_DENIED
            );
            return;
        }
    }

    // set the list of connections for this sphere
    yield put(
        roomDataActions.setConnectionsForSphereInRoomRequestAction(
            connections,
            sphereId,
            action.roomName
        )
    );

    // tell the client it connected the spheres OK
    let connectSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS_SUCCESS,
        { [ outgoingMsgComponents.SET_SPHERE_CONNECTIONS_SUCCESS.SPHERE_ID ]: sphereId }
    );

    sendWsMessageWithLogger( action.ws, connectSuccessMessage, logger );

    // broadcast the new connections to the room
    let outgoingMsgData = {
        [ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.SPHERE_ID ]:    sphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.CONNECTIONS ]:  connections,
        [ outgoingMsgComponents.ROOM_SPHERE_CONNECTIONS_SET.CLIENT_ID ]:    action.ws.id
    };

    let sphereConnectionsMsg = makeWsBroadcastMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_CONNECTIONS_SET,
        outgoingMsgData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereConnectionsMsg, action.roomName ) );

};

/*******************************************************************************
* DELETE SPHERE BY ID
*******************************************************************************/

const deleteSphere = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let sphereState = yield getSphereStateForRoom( action.roomName );

    // snag relevant data
    let sphereId    = msgData[ incomingMsgComponents.DELETE_SPHERE.SPHERE_ID ];

    // does this sphere exist?
    if( !sphereState[ sphereId ] ) {

        denySphereAction(
            action.ws,
            sphereId,
            null,    // it would be invalid for this ws to grab it
            messageConstants.ERROR_TYPES.NON_EXISTENT_SPHERE
        );

        return;
    };

    // is it held at all?
    if( sphereState[ sphereId ].hold ) {

        let holderId = sphereState[ sphereId ].hold.clientId;

        if( typeof holderId === 'undefined' ) {

            denySphereAction(
                action.ws,
                sphereId,
                null,
                messageConstants.ERROR_TYPES.SYSTEM_ERROR
            );

            return;
        }

        // is it held by another client?
        if( holderId !== action.ws.id ) {

            denySphereAction(
                action.ws,
                sphereId,
                holderId,  // denied because this other one's holding it
                messageConstants.OUTGOING_MESSAGE_TYPES.DELETE_SPHERE_DENIED
            );

            return;
        }
    }

    // remove the hold on this sphere for this client
    yield put(
        roomDataActions.deleteSphereFromRoomRequestAction(
            sphereId,
            action.roomName
        )
    );

    // tell the client it deleted the sphere OK
    let deleteSuccessMessage = makeWsReplyMessage(
        config.serverId,
        messageConstants.OUTGOING_MESSAGE_TYPES.DELETE_SPHERE_SUCCESS,
        { [ outgoingMsgComponents.DELETE_SPHERE_SUCCESS.SPHERE_ID ]: sphereId }
    );

    sendWsMessageWithLogger( action.ws, deleteSuccessMessage, logger );

    // broadcast 'sphere deleted' action
    let outgoingMsgData = {
        [ outgoingMsgComponents.ROOM_SPHERE_DELETED.SPHERE_ID ]: sphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_DELETED.CLIENT_ID ]: action.ws.id
    };

    let sphereDeletedMsg = makeWsBroadcastMessage(
        action.ws.id,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_DELETED,
        outgoingMsgData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereDeletedMsg, action.roomName ) );

};

/*******************************************************************************
* STRIKE SPHERE BY ID
*******************************************************************************/

const strikeSphere = function* ( action ) {

    let { msgType, msgData } = getTypeAndDataForMessage( action );

    // get necessary state
    let sphereState = yield getSphereStateForRoom( action.roomName );

    // snag relevant data
    let sphereId    = msgData[ incomingMsgComponents.STRIKE_SPHERE.SPHERE_ID ];
    let velocity    = msgData[ incomingMsgComponents.STRIKE_SPHERE.VELOCITY ];
    let fromId      = action[ incomingMsgComponents.ALL_MESSAGES.FROM ];

    // does sphere exist?
    if( !sphereState[ sphereId ] ) {
        // drop silently if no such sphere
        return;
    };

    // no state change here, just broadcast 'sphere strike' action
    let outgoingMsgData = {
        [ outgoingMsgComponents.ROOM_SPHERE_STRUCK.SPHERE_ID ]: sphereId,
        [ outgoingMsgComponents.ROOM_SPHERE_STRUCK.VELOCITY ]:  velocity,
        [ outgoingMsgComponents.ROOM_SPHERE_STRUCK.CLIENT_ID ]: fromId
    };

    let sphereStruckMsg = makeWsBroadcastMessage(
        action.ws.id,
        messageConstants.OUTGOING_MESSAGE_TYPES.ROOM_SPHERE_STRUCK,
        outgoingMsgData
    );

    yield put( roomDataActions.publishMessageToRoomRequestAction( sphereStruckMsg, action.roomName ) );

};

/*******************************************************************************
* HELPER FUNCTIONS
*******************************************************************************/

const getTypeAndDataForMessage = ( message ) => {
    return {
        msgType: message[ incomingMsgComponents.ALL_MESSAGES.TYPE ],
        msgData: message[ incomingMsgComponents.ALL_MESSAGES.MSG ][ incomingMsgComponents.ALL_MESSAGES.DATA ]
    };
};

const getSphereStateForRoom = function* ( roomName ) {

    let sphereState = yield select(
        ( state ) => {
            return state.roomDataReducer.rooms[ roomName ].content.spheres;
        }
    );

    return sphereState;
};

const denySphereAction = ( ws, sphereId, holderId, denialType ) => {


    let data = {};

    if( sphereId ) {
        data[ outgoingMsgComponents.SPHERE_ACTION_DENIED.SPHERE_ID ] = sphereId;
    }

    if( holderId ) {
        data[ outgoingMsgComponents.SPHERE_ACTION_DENIED.HOLDER_ID ] = holderId;
    }

    let actionDeniedMessage = makeWsReplyMessage(
        config.serverId,
        denialType,
        data
    );

    sendWsMessageWithLogger( ws, actionDeniedMessage, logger );
    return;
};

/*******************************************************************************
* SAGA WATCHER FUNCTIONS
* these takeEvery clauses match for INCOMING_MESSAGE_TYPES auto-dispatched by
* messageHandler.handleWebsocketMessage() after message validation
*******************************************************************************/

const watchExitRoomRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.EXIT_ROOM,
        exitRoom
    );
};

const watchUpdateCoordsForClientRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.UPDATE_CLIENT_COORDS,
        updateClientCoords
    );
};

const watchCreateSphereAtPositionRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.CREATE_SPHERE_OF_TONE_AT_POSITION,
        createSphereOfToneAtPosition
    );
};

const watchGrabSphereRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.GRAB_SPHERE,
        grabSphere
    );
};

const watchReleaseSphereRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.RELEASE_SPHERE,
        releaseSphere
    );
};

const watchDeleteSphereRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.DELETE_SPHERE,
        deleteSphere
    );
};

const watchSetSphereToneRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.SET_SPHERE_TONE,
        setSphereTone
    );
};

const watchSetSphereConnectionsRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.SET_SPHERE_CONNECTIONS,
        setSphereConnections
    );
};

const watchStrikeSphereRequests = function* () {
    yield takeEvery(
        messageConstants.INCOMING_MESSAGE_TYPES.STRIKE_SPHERE,
        strikeSphere
    );
};

export default {
    rootSaga: function* () {
        const sagas = [
            watchExitRoomRequests,
            watchUpdateCoordsForClientRequests,
            watchCreateSphereAtPositionRequests,
            watchGrabSphereRequests,
            watchReleaseSphereRequests,
            watchDeleteSphereRequests,
            watchSetSphereToneRequests,
            watchSetSphereConnectionsRequests,
            watchStrikeSphereRequests
        ];

        yield sagaUtils.spawnAutoRestartingSagas( sagas );
    }
};


================================================
FILE: backend/src/messages/message-schema.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/*******************************************************************************
*
* this module implements a JSON schema for incoming messages.
* it's specified directly as JS objects so we can:
*
* - have comments in the schema
* - include other sections without needing separate JSON files
* - distinguish between JSON schema property names (barewords)
*   and schema entry types ('quoted')
* - dynamically construct the schema's "properties" list
*   from INCOMING_MESSAGE_TYPES constants
*
*******************************************************************************/

import { sphereConstants }  from '../spheres';
import messageConstants     from './message-constants';

// shorthand for dictionary of INCOMING_MESSAGE_TYPES constants
let messageTypes        = messageConstants.INCOMING_MESSAGE_TYPES;
let messageComponents   = messageConstants.INCOMING_MESSAGE_COMPONENTS;


/*******************************************************************************
* UTILITY FUNCTIONS
*******************************************************************************/

// make 'type: "string"' schema definition entry using a messageTypes constant
const makeStringTypeForMessageTypeName = ( name ) => {
    return {
        type: 'string',
        minLength:  ( () => { return name.length; } )(),
        maxLength:  ( () => { return name.length; } )()
    }
};

/*******************************************************************************
* INCOMING MESSAGE TYPE SCHEMA DEFINITIONS
*******************************************************************************/

// exit current room
const exitRoomMessageSchema = {

    type: 'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => {
            return makeStringTypeForMessageTypeName( messageTypes.EXIT_ROOM );
        } )()

    },

    required: [ messageComponents.ALL_MESSAGES.TYPE ]
};

// "client position update" message
const updateClientCoordsMessageSchema = {

    type: 'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => {
            return makeStringTypeForMessageTypeName( messageTypes.UPDATE_CLIENT_COORDS );
        } )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.UPDATE_CLIENT_COORDS.HEAD ]:    { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
                [ messageComponents.UPDATE_CLIENT_COORDS.LEFT ]:    { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
                [ messageComponents.UPDATE_CLIENT_COORDS.RIGHT ]:   { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE_SET}` },
                // optional spheres, only used if client is holding spheres
                [ messageComponents.UPDATE_CLIENT_COORDS.SPHERES ]: {
                    type: 'array',
                    items: {
                        type: 'object',
                        properties: {
                            [ messageComponents.UPDATE_CLIENT_COORDS.SPHERE_ID ]: {
                                type:   'string',
                                format: 'uuid'
                            },
                            [ messageComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
                        },
                        required: [
                            messageComponents.UPDATE_CLIENT_COORDS.SPHERE_ID,
                            messageComponents.UPDATE_CLIENT_COORDS.SPHERE_POSITION
                        ]
                    }
                }
            },

            required: [
                messageComponents.UPDATE_CLIENT_COORDS.HEAD,
                messageComponents.UPDATE_CLIENT_COORDS.LEFT,
                messageComponents.UPDATE_CLIENT_COORDS.RIGHT
            ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'create sphere at position' message
const createSphereOfToneAtPositionMessageSchema = {

    type: 'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.CREATE_SPHERE_OF_TONE_AT_POSITION ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE ]: {
                    type:  'number',
                    minimum:    sphereConstants.SPHERE_INFO.LOWEST_TONE,
                    maximum:    sphereConstants.SPHERE_INFO.HIGHEST_TONE
                },
                [ messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
            },

            required: [
                messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.TONE,
                messageComponents.CREATE_SPHERE_OF_TONE_AT_POSITION.POSITION
            ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'grab sphere'
const grabSphereMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.GRAB_SPHERE ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.GRAB_SPHERE.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                }
            },

            required: [ messageComponents.GRAB_SPHERE.SPHERE_ID ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'release sphere'
const releaseSphereMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.RELEASE_SPHERE ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.RELEASE_SPHERE.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                }
            },

            required: [ messageComponents.RELEASE_SPHERE.SPHERE_ID ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'delete sphere'
const deleteSphereMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.DELETE_SPHERE ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.DELETE_SPHERE.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                }
            },

            required: [ messageComponents.DELETE_SPHERE.SPHERE_ID ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'strike sphere'
const strikeSphereMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.STRIKE_SPHERE ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.STRIKE_SPHERE.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                },
                [ messageComponents.STRIKE_SPHERE.VELOCITY ]: {
                    type:       'number',
                    minimum:    sphereConstants.SPHERE_INFO.MINIMUM_STRIKE_VELOCITY,
                    maximum:    sphereConstants.SPHERE_INFO.MAXIMUM_STRIKE_VELOCITY
                }
            },

            required: [
                messageComponents.STRIKE_SPHERE.SPHERE_ID,
                messageComponents.STRIKE_SPHERE.VELOCITY
            ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'set sphere tone'
const setSphereToneMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.SET_SPHERE_TONE ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.SET_SPHERE_TONE.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                },
                [ messageComponents.SET_SPHERE_TONE.TONE ]: {
                    type:       'number',
                    minimum:    sphereConstants.SPHERE_INFO.LOWEST_TONE,
                    maximum:    sphereConstants.SPHERE_INFO.HIGHEST_TONE
                }
            },

            required: [
                messageComponents.SET_SPHERE_TONE.SPHERE_ID,
                messageComponents.SET_SPHERE_TONE.TONE
            ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

// 'set sphere connections'
const setSphereConnectionsMessageSchema = {

    type:       'object',

    properties: {

        [ messageComponents.ALL_MESSAGES.TYPE ]: ( () => makeStringTypeForMessageTypeName( messageTypes.SET_SPHERE_CONNECTIONS ) )(),

        [ messageComponents.ALL_MESSAGES.DATA ]: {

            type: 'object',

            properties: {
                [ messageComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID ]: {
                    type:   'string',
                    format: 'uuid'
                },
                [ messageComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS ]: {
                    type:     'array',
                    items:    {
                        type:   'string',
                        format: 'uuid'
                    }
                }
            },

            required: [
                messageComponents.SET_SPHERE_CONNECTIONS.SPHERE_ID,
                messageComponents.SET_SPHERE_CONNECTIONS.CONNECTIONS
            ]
        }
    },

    required: [
        messageComponents.ALL_MESSAGES.TYPE,
        messageComponents.ALL_MESSAGES.DATA
    ]
};

/*******************************************************************************
* MAPPING FROM INCOMING MESSAGE TYPES TO APPROPRIATE SCHEMA COMPONENTS
********************************************************************************/

const incomingMessageSchemaDefinitions = ( () => {

    let definitions = {};
    let m = messageTypes;

    definitions[ m.EXIT_ROOM ]                          = exitRoomMessageSchema;
    definitions[ m.UPDATE_CLIENT_COORDS ]               = updateClientCoordsMessageSchema;
    definitions[ m.CREATE_SPHERE_OF_TONE_AT_POSITION ]  = createSphereOfToneAtPositionMessageSchema;
    definitions[ m.GRAB_SPHERE ]                        = grabSphereMessageSchema;
    definitions[ m.RELEASE_SPHERE ]                     = releaseSphereMessageSchema;
    definitions[ m.DELETE_SPHERE ]                      = deleteSphereMessageSchema;
    definitions[ m.STRIKE_SPHERE ]                      = strikeSphereMessageSchema;
    definitions[ m.SET_SPHERE_TONE ]                    = setSphereToneMessageSchema;
    definitions[ m.SET_SPHERE_CONNECTIONS ]             = setSphereConnectionsMessageSchema;

    return definitions;

})();


/*******************************************************************************
* COMPONENT DEFINITIONS REFERRED TO IN MESSAGE TYPES, MUST BE INCLUDED IN SCHEMA
********************************************************************************/

const componentDefinitions = {

    // an x-y-z coordinate
    [ messageComponents.REFERENCES.COORDINATE ]: {

        type: 'object',

        properties: {
            [ messageComponents.REF_COORDINATE.X ]: { type: 'number' },
            [ messageComponents.REF_COORDINATE.Y ]: { type: 'number' },
            [ messageComponents.REF_COORDINATE.Z ]: { type: 'number' }
        },

        required: [
            messageComponents.REF_COORDINATE.X,
            messageComponents.REF_COORDINATE.Y,
            messageComponents.REF_COORDINATE.Z
        ]
    },

    // a pair of coordinates for rotation/position
    [ messageComponents.REFERENCES.COORDINATE_SET ]: {

        type: 'object',

        properties: {
            [ messageComponents.REF_COORDINATE_SET.POSITION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` },
            [ messageComponents.REF_COORDINATE_SET.ROTATION ]: { '$ref': `#/definitions/${messageComponents.REFERENCES.COORDINATE}` }
        },

        required: [
            messageComponents.REF_COORDINATE_SET.POSITION,
            messageComponents.REF_COORDINATE_SET.ROTATION
        ]
    }
};

/*******************************************************************************
* COMBINED LIST OF COMPONENT AND MESSAGE DEFINITIONS INCLUDED IN MAIN SCHEMA
********************************************************************************/

const messageSchemaDefinitions = Object.assign(
    componentDefinitions,
    incomingMessageSchemaDefinitions
);


/*******************************************************************************
* PROPERTIES USED IN MAIN SCHEMA
********************************************************************************/

/*
 * list of message types defined as properties in schema,
 * dynamically defined from the list of incoming message schema definitions
 *
 * comes out looking like this, as per JSON schema reqs:
 *
 * properties = {
 *     room_client_position_update: {
 *         '$ref': '#/definitions/room_client_position_update'
 *     }
 * }
 */
const messageSchemaProperties = ( () => {

    let properties = {};

    Object.keys( incomingMessageSchemaDefinitions ).forEach(
        ( messageTypeName ) => {
            properties[ messageTypeName ] = {
                '$ref': `#/definitions/${messageTypeName}`
            }
        }
    );

    return properties;
})();

/*******************************************************************************
* MAIN SCHEMA OBJECT INCORPORATING DEFINITIONS AND PROPERTIES FROM ABOVE
*******************************************************************************/

const messageSchema = {

    // these are type definitions shared by multiple objects
    definitions: messageSchemaDefinitions,

    // these are the various types
    properties: messageSchemaProperties
};

export {
    messageSchema
}


================================================
FILE: backend/src/messages/message-validator.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import ajv                  from 'ajv';

import { deserialize }      from '../s11n';
import { messageSchema }    from './message-schema';

import messageConstants    from './message-constants';

const messageComponents = messageConstants.INCOMING_MESSAGE_COMPONENTS;

/*
 * create a validator object for all incoming messages
 */
let ajvOptions = {
    allErrors:          true,   // log all validation errors, not just first
    removeAdditional:   "all"   // this modifies the original message(!)
};
let validator   = new ajv( ajvOptions );
let validate    = validator.compile( messageSchema );

let messageTypesNotRequiringData = [
    messageConstants.INCOMING_MESSAGE_TYPES.EXIT_ROOM
];

/*
 * validates messages received over the wire against the appropriate schema.
 * called from messageHandler:handleWebsocketMessageWithStore().
 *
 * any kind of validation issue throws an exception, and messageHandler
 * drops the message.
 */
const validateMessage = ( wireMessage ) => {

    let deserializedMessage;

    try {
        deserializedMessage = deserialize( wireMessage );
    }
    catch( error ) {
        throw error;
    }

    // only validate if there's a message type
    if( !deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) {
        throw new Error( `incoming message has no '${messageComponents.ALL_MESSAGES.TYPE}' property` );
    }

    // only validate if there's message data
    if( !deserializedMessage[ messageComponents.ALL_MESSAGES.DATA ] ) {
        // except for message types that don't require message data
        if( messageTypesNotRequiringData.indexOf( deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) < 0 ) {
            throw new Error( `incoming message has no '${messageComponents.ALL_MESSAGES.DATA}' property` );
        }
    }

    // only validate if it's an acceptable message type
    if( !isValidMessageType( deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] ) ) {
        throw new Error( `incoming message has invalid type '${deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ]}'` );
    }

    // run the validator
    return validateMessageOfType( deserializedMessage, deserializedMessage[ messageComponents.ALL_MESSAGES.TYPE ] );
};

/*
 * check the provided type is in the top level 'properties' of the messageSchema
 */
const isValidMessageType = ( type ) => {
    return Object.keys( messageSchema.properties ).indexOf( type ) > -1;
};

/*
 * validator function for incoming messages
 */
const validateMessageOfType = ( message, type ) => {

    let objectToValidate = {};
    objectToValidate[ type ] = message;

    let valid = validate( objectToValidate )

    if( valid !== true ) {
        let errorMsgs = validate.errors.map(
            ( error ) => {
                return `${error.dataPath} ${error.message}`;
            }
        );
        let errorMsg = errorMsgs.join( '; ' );
        throw new Error( errorMsg );
    }

    return message;
};

export default {
    validateMessage
};


================================================
FILE: backend/src/pubsub/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import pubsubClient     from './pubsub-client';

export {
    pubsubClient
};


================================================
FILE: backend/src/pubsub/pubsub-client.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import pubsub           from '@google-cloud/pubsub';
import config           from '../config';
import { logger }       from '../logger';

let client = ( ( conf ) => {

    if( !client ) {
        if( process.env.PUBSUB_EMULATOR_HOST ) {
            logger.info( `using PubSub emulator running at ${process.env.PUBSUB_EMULATOR_HOST}` );
        }
        else {
            logger.info( `using live PubSub on GCP` );
        }

        client = pubsub( conf );
    }

    return client;
})( config );

const createPubsubTopic = function* ( topicName ) {
    return yield client.createTopic( topicName );
};

const getPubsubTopic = function* ( topicName ) {
    return yield client.topic( topicName );
};

export default {
    createPubsubTopic,
    getPubsubTopic
};


================================================
FILE: backend/src/rooms/index.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import roomDataActions      from './room-data-actions';
import roomDataConstants    from './room-data-constants';
import roomDataReducer      from './room-data-reducer';
import roomNames            from './room-names';
import roomSagas            from './room-sagas';
import roomStateActions     from './room-state-actions';
import roomStateConstants   from './room-state-constants';
import roomStateReducer     from './room-state-reducer';
import roomStateUtils       from './room-state-utils';

export {
    roomDataActions,
    roomDataConstants,
    roomDataReducer,
    roomNames,
    roomSagas,
    roomStateActions,
    roomStateConstants,
    roomStateReducer,
    roomStateUtils
};


================================================
FILE: backend/src/rooms/room-data-actions.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import constants from './room-data-constants';

/*******************************************************************************
* ADD/REMOVE CLIENTS TO/FROM ROOMS
*******************************************************************************/

const addWebsocketToQueueForRoomRequestAction = ( wsId, roomName ) => {
    let type = constants.ACTION_TYPES.ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM;
    return { type, wsId, roomName };
};

const removeWebsocketFromQueueForRoomRequestAction = ( wsId, roomName ) => {
    let type = constants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM;
    return { type, wsId, roomName };
};

const addLocalClientToRoomRequestAction = ( clientId, clientHeadsetType, roomName ) => {
    let type = constants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM;
    return { type, clientId, clientHeadsetType, roomName };
};

const removeLocalClientFromRoomRequestAction = ( clientId, roomName ) => {
    let type = constants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM;
    return { type, clientId, roomName };
};

/*******************************************************************************
* ROOM SETUP/MAINTAIN/TEARDOWN
*******************************************************************************/

const setLastHeartbeatCountForRoomRequestAction = ( count, roomName ) => {
    let type = constants.ACTION_TYPES.UPDATE_LAST_HEARTBEAT_FOR_ROOM;
    return { type, count, roomName };
};

const setHeartbeatTaskForRoomRequestAction = ( heartbeatTask, roomName ) => {
    let type = constants.ACTION_TYPES.SET_HEARTBEAT_TASK_FOR_ROOM;
    return { type, heartbeatTask, roomName };
};

const removeHeartbeatTaskForRoomRequestAction = ( roomName ) => {
    let type = constants.ACTION_TYPES.REMOVE_HEARTBEAT_TASK_FOR_ROOM;
    return { type, roomName };
};

const cancelHeartbeatTaskForRoomRequestAction = ( roomName ) => {
    let type = constants.ACTION_TYPES.CANCEL_HEARTBEAT_TASK_FOR_ROOM;
    return { type, roomName };
};

const checkForEmptyRoomRequestAction = ( roomName ) => {
    let type = constants.ACTION_TYPES.CHECK_FOR_EMPTY_ROOM;
    return { type, roomName };
};

/*******************************************************************************
* SPHERE STATE ACTIONS
*******************************************************************************/

const addSphereOfToneAtPositionInRoomRequestAction = ( newSphereId, tone, position, roomName ) => {
    let type = constants.ACTION_TYPES.ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM;
    return { type, newSphereId, tone, position, roomName };
};

const createHoldOnSphereForClientInRoomRequestAction = ( sphereId, clientId, roomName ) => {
    let type = constants.ACTION_TYPES.CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM;
    return { type, sphereId, clientId, roomName };
};

const setHoldTimeoutTaskForSphereInRoomRequestAction = ( timeoutTask, sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM;
    return { type, timeoutTask, sphereId, roomName };
};

const deleteHoldTimeoutTaskForSphereInRoomRequestAction = ( sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM;
    return { type, sphereId, roomName };
};

const setPositionForSphereInRoomRequestAction = ( position, sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.SET_POSITION_FOR_SPHERE_IN_ROOM;
    return { type, position, sphereId, roomName };
};

const setToneForSphereInRoomRequestAction = ( tone, sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.SET_TONE_FOR_SPHERE_IN_ROOM;
    return { type, tone, sphereId, roomName };
};

const setConnectionsForSphereInRoomRequestAction = ( connections, sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.SET_CONNECTIONS_FOR_SPHERE_IN_ROOM;
    return { type, connections, sphereId, roomName };
};

const removeHoldOnSphereForClientInRoomRequestAction = ( sphereId, clientId, roomName ) => {
    let type = constants.ACTION_TYPES.REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM;
    return { type, sphereId, clientId, roomName };
};

const deleteSphereFromRoomRequestAction = ( sphereId, roomName ) => {
    let type = constants.ACTION_TYPES.DELETE_SPHERE_FROM_ROOM;
    return { type, sphereId, roomName };
};

/*******************************************************************************
* PUBLISH MESSAGE
*******************************************************************************/

const publishMessageToRoomRequestAction = ( message, roomName ) => {
    let type = constants.ACTION_TYPES.PUBLISH_MESSAGE_TO_ROOM;
    return { type, message, roomName };
};

export default {

    // add/remove clients to/from rooms
    addWebsocketToQueueForRoomRequestAction,
    removeWebsocketFromQueueForRoomRequestAction,
    addLocalClientToRoomRequestAction,
    removeLocalClientFromRoomRequestAction,

    // room setup/maintain/teardown
    setLastHeartbeatCountForRoomRequestAction,
    setHeartbeatTaskForRoomRequestAction,
    removeHeartbeatTaskForRoomRequestAction,
    cancelHeartbeatTaskForRoomRequestAction,
    checkForEmptyRoomRequestAction,

    // sphere state actions
    addSphereOfToneAtPositionInRoomRequestAction,
    createHoldOnSphereForClientInRoomRequestAction,
    setHoldTimeoutTaskForSphereInRoomRequestAction,
    deleteHoldTimeoutTaskForSphereInRoomRequestAction,
    setPositionForSphereInRoomRequestAction,
    setToneForSphereInRoomRequestAction,
    setConnectionsForSphereInRoomRequestAction,
    removeHoldOnSphereForClientInRoomRequestAction,
    deleteSphereFromRoomRequestAction,

    // publish message saga action
    publishMessageToRoomRequestAction

};


================================================
FILE: backend/src/rooms/room-data-constants.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { HEADSET_TYPES } from '../messages/message-constants';

const ACTION_TYPES = {

    ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM:                'add_websocket_to_queue_for_room',
    REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM:           'remove_websocket_from_queue_for_room',
    ADD_LOCAL_CLIENT_TO_ROOM:                       'add_local_client_to_room',
    REMOVE_LOCAL_CLIENT_FROM_ROOM:                  'remove_local_client_from_room',

    // room setup/teardown
    UPDATE_LAST_HEARTBEAT_FOR_ROOM:                 'update_last_heartbeat_for_room',
    SET_HEARTBEAT_TASK_FOR_ROOM:                    'set_heartbeat_task_for_room',
    REMOVE_HEARTBEAT_TASK_FOR_ROOM:                 'remove_heartbeat_task_for_room',
    CANCEL_HEARTBEAT_TASK_FOR_ROOM:                 'cancel_heartbeat_task_for_room',
    CHECK_FOR_EMPTY_ROOM:                           'check_for_empty_room',

    // client state actions
    SET_COORDS_FOR_CLIENT_IN_ROOM:                  'set_coords_for_client_in_room',
    REMOVE_CLIENT_FROM_ROOM:                        'remove_client_from_room',

    // sphere state actions
    ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM:         'add_sphere_of_tone_at_position_in_room',
    SET_POSITION_FOR_SPHERE_IN_ROOM:                'set_position_for_sphere_in_room',
    SET_TONE_FOR_SPHERE_IN_ROOM:                    'set_tone_for_sphere_in_room',
    SET_CONNECTIONS_FOR_SPHERE_IN_ROOM:             'set_connections_for_sphere_in_room',
    CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM:       'create_hold_on_sphere_for_client_in_room',
    SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM:       'set_hold_timeout_task_for_sphere_in_room',
    DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM:    'delete_hold_timeout_task_for_sphere_in_room',
    REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM:       'remove_hold_on_sphere_for_client_in_room',
    DELETE_SPHERE_FROM_ROOM:                        'delete_sphere_from_room',

    // publish to clients in room
    PUBLISH_MESSAGE_TO_ROOM:                        'publish_message_to_room'
};

const CLIENT_INFO = {
    CLIENT_TYPE_LOCAL:  'client_type_local',
    CLIENT_TYPE_REMOTE: 'client_type_remote',
    CLIENT_INACTIVITY_TIMEOUTS_IN_MS: {
        [ HEADSET_TYPES.HEADSET_TYPE_3DOF ]:    120000, // 2m
        [ HEADSET_TYPES.HEADSET_TYPE_6DOF ]:    120000, // 2m
        [ HEADSET_TYPES.HEADSET_TYPE_VIEWER ]:  120000, // 2m
    },
    CLIENT_INACTIVITY_TIMEOUT_CHECKS_IN_MS: {
        [ HEADSET_TYPES.HEADSET_TYPE_3DOF ]:    30000, // 30s
        [ HEADSET_TYPES.HEADSET_TYPE_6DOF ]:    30000, // 30s
        [ HEADSET_TYPES.HEADSET_TYPE_VIEWER ]:  30000, // 30s
    }
};

const ERROR_TYPES = {
    ROOM_TIMEOUT:       'room_timeout',
    JOINING_ROOM:       'error_joining_room',
    ROOM_FULL:          'error_room_full',
    WAITING_FOR_STATE:  'waiting_for_state'
};

const ROOM_INFO = {
    MINIMUM_SOUNDBANK_NUMBER:   0,
    MAXIMUM_SOUNDBANK_NUMBER:   2
};

const SPHERE_INFO = {
    SPHERE_HOLD_TIMEOUT_IN_MS:          10000,
    SPHERE_HOLD_TIMEOUT_CHECK_IN_MS:    2500
};

export default {
    ACTION_TYPES,
    CLIENT_INFO,
    ERROR_TYPES,
    ROOM_INFO,
    SPHERE_INFO
};


================================================
FILE: backend/src/rooms/room-data-reducer.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import redux                from 'redux';
import uuid                 from 'uuid';
import sizeof               from 'object-sizeof';
import util                 from 'util';

import {
    initStateObject,
    createFilteredActionHandler
}  from '../utils/reducer-utils';

import { logger }           from '../logger';
import messageConstants     from '../messages/message-constants';

const HT_3DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
const HT_6DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
const HT_VIEW   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;

const CONNECTIONS_LABEL = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.CONNECTIONS;
const POSITION_LABEL    = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.POSITION;
const TONE_LABEL    = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_STATUS_INFO.TONE;

import {
    makeSphereOfToneAtPosition,
    sphereConstants,
    trees
}  from '../spheres';

import config               from '../config';

import roomDataConstants    from './room-data-constants';
import roomStateConstants   from './room-state-constants';
import roomNames            from './room-names';

/*******************************************************************************
* HELPER FUNCTIONS
*******************************************************************************/

const createEmptyRoom = () => {
    return {

        heartbeatTask:      null,
        lastHeartbeat:      {
            count:      0,
            timestamp:  0
        },

        setupComplete:              false,
        waitingForStateStatus:      false,
        checkingForEmptyRoomStatus: false,

        soundbank:          null,
        spheres:            {},

        websockets:         {},
        clients:            {}
    };
};


const createNewRoom = () => {
    let emptyRoom = createEmptyRoom();

    emptyRoom.soundbank = getRandomInt(
        roomDataConstants.ROOM_INFO.MINIMUM_SOUNDBANK_NUMBER,
        roomDataConstants.ROOM_INFO.MAXIMUM_SOUNDBANK_NUMBER
    );

    emptyRoom.spheres   = generateSpheresForRoom( emptyRoom.soundbank );

    return emptyRoom;
};

const generateSpheresForRoom = ( soundbank ) => {

    const roomSpheres = {}

    function dist(a, b){
        return Math.sqrt(Math.pow(a.x - b.x, 2) + 
                         // Math.pow(a.y - b.y, 2) + 
                         Math.pow(a.z - b.z, 2))
    }

    function randomPos(){
        const rad = 2.5
        const pos = {
            x : Math.random() * rad - rad/2,
            y : Math.random() * 0.7 + 0.9,
            z : Math.random() * rad - rad/2
        }
        // check that the position is not too close to any other sphere
        let closestDist = Infinity
        for (let id in roomSpheres){
            const roomPos = roomSpheres[id][POSITION_LABEL]
            closestDist = Math.min(dist(pos, roomPos), closestDist)
        }
        //only return the position if its greater than the thresh
        if (closestDist > 0.5){
            return pos
        } else {
        //otherwise generate a new one
            return randomPos()
        }
    }

    for (let i = 0; i < 10; i++){
        roomSpheres[uuid()] = {
            [TONE_LABEL] : getRandomInt(sphereConstants.SPHERE_INFO.LOWEST_TONE, 
                                        sphereConstants.SPHERE_INFO.HIGHEST_TONE),
            [POSITION_LABEL] : randomPos()
        }
    }

    // return spheres
    return roomSpheres;
};

const getRandomInt = ( min, max ) => {
    let intMin = Math.ceil( min );
    let intMax = Math.floor( max );
    return Math.floor( Math.random() * ( intMax - intMin + 1 ) ) + intMin;
};

const getZeroCoordsForNewClient = () => {
    return {
        head: {
            position: { x: 0, y: 0, z: 0 },
            rotation: { x: 0, y: 0, z: 0 }
        },
        left: {
            position: { x: 0, y: 0, z: 0 },
            rotation: { x: 0, y: 0, z: 0 }
        },
        right: {
            position: { x: 0, y: 0, z: 0 },
            rotation: { x: 0, y: 0, z: 0 }
        }
    };
};

/*******************************************************************************
* GENERATE INITIAL EMPTY STATE FOR ALL ROOMS
*******************************************************************************/

// dictionary of empty room data objects for all names
const initialState = ( () => {

    let createFunction = () => {
        return {
            queue:      [],
            tasks:      {}
        };
    };

    return {
        rooms: initStateObject( roomNames, createFunction, 'room data objects', logger ),
        avails:     ( () => {
            let obj = {};
            Object.values( messageConstants.HEADSET_TYPES ).forEach(
                ( headsetType ) => {
                    obj[ headsetType ] = {};
                }
            );
            return obj;
        } )()
    };

})();


/*******************************************************************************
* ROOM INFO/STATUS REDUCER FUNCTIONS
*******************************************************************************/

// create a new room state including spheres
// action: { roomName }
const initRoomContent = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].content = createNewRoom();
    logger.trace( `created default content for new room ${action.roomName}` );
    return state;
};

// action: { wsId, roomName }
const addWebsocketToQueueForRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( state.rooms[ action.roomName ].queue.indexOf( action.wsId ) >= 0 ) {
        return state;
    }
    state.rooms[ action.roomName ].queue.push( action.wsId );
    logger.trace( `[+] ${state.rooms[ action.roomName ].queue.length} websockets in queue for room ${action.roomName}` );
    return state;
};

// action: { wsId, roomName }
const removeWebsocketFromQueueForRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].queue = state.rooms[ action.roomName ].queue.filter(
        ( clientId ) => {
            return clientId != action.wsId;
        }
    );
    logger.trace( `[-] ${state.rooms[ action.roomName ].queue.length} websockets in queue for room ${action.roomName}` );
    return state;
};

/*******************************************************************************
* CLIENT REDUCER FUNCTIONS
*******************************************************************************/

// action: { clientId, roomName }
const addLocalClientToRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    let roomClients         = state.rooms[ action.roomName ].content.clients;

    if( typeof roomClients[ action.clientId ] !== 'undefined' ) {
        return state;
    }

    roomClients[ action.clientId ] = {
        headsetType:    action.clientHeadsetType,
        coords:         getZeroCoordsForNewClient(),
        spheresHeld:    {}
    }

    logger.trace( `[+] there are now ${Object.keys( roomClients ).length} total clients in room '${action.roomName}'` );

    return updateClientCountsForRoom( state, action );
};

// action: { clientId, roomName }
const removeLocalClientFromRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].content.clients[ action.clientId ] = null;
    delete state.rooms[ action.roomName ].content.clients[ action.clientId ];

    let roomClients = state.rooms[ action.roomName ].content.clients;
    logger.trace( `[-] there are now ${Object.keys( roomClients ).length} total clients in room '${action.roomName}'` );

    return updateClientCountsForRoom( state, action );
};

/*
 *  for a room to be available to 6dof:
 *
 *      6dof must be less than 6dofThreshold
 *      3dof + 6dof must be less than 3dofThreshold + 6dofThreshold
 *      3dof + 6dof + viewers must be less than maxClientsPerRoom
 *
 *  for a room to be available to 3dof:
 *
 *      3dof must be less than 3dofThreshold
 *      3dof + 6dof must be less than 3dofThreshold + 6dofThreshold
 *      3dof + 6dof + viewers must be less than maxClientsPerRoom
 *
 *  for a room to be available to viewer:
 *
 *      3dof + 6dof + viewers must be less than maxClientsPerRoom
 */
const updateClientCountsForRoom = ( state, action ) => {

    // this gives { '3dof': 2, '6dof': 1, viewer: 4 }
    let clientCounts = Object.values( state.rooms[ action.roomName ].content.clients ).reduce(
        ( acc, client ) => {
            acc[ client.headsetType ] = acc[ client.headsetType ] + 1;
            return acc;
        },
        ( () => {
            let obj = {};
            Object.values( messageConstants.HEADSET_TYPES ).forEach(
                ( headsetType ) => {
                    obj[ headsetType ] = 0;
                }
            );
            return obj;
        } )()
    );

    // this gives { '3dof': 2 + '6dof': 1 + viewer: 4 } =  totalClients: 7
    let totalClients = Object.values( clientCounts ).reduce(
        ( acc, count ) => {
            return acc + count;
        },
        0
    );

    // if the room's full, it's not available at all;
    // if it's empty, it doesn't go in the availability lists,
    // as it'll be found from the list of INIT-state rooms
    // in roomStateReducer.roomsByState
    if( totalClients >= config.maxClientsPerRoom || totalClients === 0) {
        delete state.avails[ HT_6DOF ][ action.roomName ];
        delete state.avails[ HT_3DOF ][ action.roomName ];
        delete state.avails[ HT_VIEW ][ action.roomName ];
        return state;
    }
    else {
        state.avails[ HT_VIEW ][ action.roomName ] = true;
    }

    let num6dof     = clientCounts[ HT_6DOF ];
    let num3dof     = clientCounts[ HT_3DOF ];
    let numViewers  = clientCounts[ HT_VIEW ];

    let threshold6dof   = config.headsetRules[ HT_6DOF ].threshold;
    let threshold3dof   = config.headsetRules[ HT_3DOF ].threshold;
    let thresholdViewer = config.headsetRules[ HT_VIEW ].threshold;

    let totalStrikers       = num6dof + num3dof;
    let strikerThreshold    = threshold6dof + threshold3dof;

    if( num6dof < threshold6dof && totalStrikers < strikerThreshold ) {
        state.avails[ HT_6DOF ][ action.roomName ] = true;
    }
    else {
        delete state.avails[ HT_6DOF ][ action.roomName ];
    }

    if( num3dof < threshold3dof && totalStrikers < strikerThreshold ) {
        state.avails[ HT_3DOF ][ action.roomName ] = true;
    }
    else {
        delete state.avails[ HT_3DOF ][ action.roomName ];
    }

    return state;
};

// action: { count, roomName }
const setLastHeartbeatCountForRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    let newHeartbeat = {
        count:      action.count,
        timestamp:  Date.now()
    };
    state.rooms[ action.roomName ].content.lastHeartbeat = newHeartbeat;
    return state;
};

// action: { heartbeatTask, roomName }
const setHeartbeatTaskForRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].tasks.heartbeat = action.heartbeatTask;
    return state;
};

// action: { roomName }
const removeHeartbeatTaskForRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].tasks.heartbeat = null;
    delete state.rooms[ action.roomName ].tasks.heartbeat;
    return state;
};

/*******************************************************************************
* SPHERE REDUCER FUNCTIONS
*******************************************************************************/

// - called via redux-action from message-sagas:createSphereAtPositionInRoom
// action: { newSphereId, position, tone, roomName }
const addSphereOfToneAtPositionInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    try {
        state.rooms[ action.roomName ].content.spheres[ action.newSphereId ] = makeSphereOfToneAtPosition(
            action.tone,
            action.position
        );
        return state;
    }
    catch( error ) {
        logger.warn( `caught error trying to create new sphere: ${error.message}` );
    }
};

// - called via redux-action from message-sagas:updateClientCoords
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// - sphere.position is a coordinates object as per message schema
// action: { position, sphereId, roomName }
const setPositionForSphereInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    const positionLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.UPDATE_CLIENT_COORDS.SPHERE_POSITION;

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ positionLabel ] = action.position;

    if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
        state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
    }

    return state;
};

// - called via redux-action from message-sagas:setSphereConnections
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// action: { connections, sphereId, roomName }
const setConnectionsForSphereInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    const connectionsLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.SET_SPHERE_CONNECTIONS.CONNECTIONS;

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ connectionsLabel ] = action.connections;

    if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
        state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
    }

    return state;
};

// - called via redux-action from message-sagas:setSphereTone
// - spheres in state are sent direct to clients, so use const labels
// - action properties are fixed rather than using messageConstants
// action: { tone, sphereId, roomName }
const setToneForSphereInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    const toneLabel = messageConstants.INCOMING_MESSAGE_COMPONENTS.SET_SPHERE_TONE.TONE;

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ][ toneLabel ] = action.tone;

    if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold ) {
        state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.ts = Date.now();
    }

    return state;
};

// - called via redux-action from message-sagas:grabSphere
// action: { sphereId, clientId, roomName }
const createHoldOnSphereForClientInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.clients[ action.clientId ] === 'undefined' ) {
        return state;
    }

    // set a hold property on the sphere
    state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold = {
        clientId: action.clientId,
        ts:         Date.now()
    };

    // mark the client as holding this sphere
    state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ] = true;

    return state;
};

// - called from room-sagas:startSphereHoldTimeout
// - triggered when a sphere hold is created
// action: { timeoutTask, sphereId, roomName }
const setHoldTimeoutTaskForSphereInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask = action.timeoutTask;

    return state;
};

// action: { sphereId, roomName }
const deleteHoldTimeoutTaskForSphereInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask = null;
    delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.timeoutTask;

    return state;
};

// - called via redux-action from message-sagas:releaseSphere
// action: { sphereId, clientId, roomName }
const removeHoldOnSphereForClientInRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.clients[ action.clientId ] === 'undefined' ) {
        return state;
    }

    if( state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold.clientId == action.clientId ) {
        // release the sphere hold so other clients can grab it
        state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold = null;
        delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ].hold;

        // disassociate the sphere from the client so it can't update it any more
        state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ] = null;
        delete state.rooms[ action.roomName ].content.clients[ action.clientId ].spheresHeld[ action.sphereId ];
    }

    return state;
};

// - called via redux-action from message-sagas:deleteSphere
// action: { sphereId, roomName }
const deleteSphereFromRoom = ( state, action ) => {

    if( typeof action.roomName === 'undefined' ) {
        return state;
    }

    if( typeof state.rooms[ action.roomName ].content.spheres[ action.sphereId ] === 'undefined' ) {
        return state;
    }

    state.rooms[ action.roomName ].content.spheres[ action.sphereId ] = null;
    delete state.rooms[ action.roomName ].content.spheres[ action.sphereId ];

    return state;
};

const actionHandlers = {

    // room setup/teardown
    [ roomStateConstants.EVENT_TYPES.INIT_ROOM_CONTENT ]:                       initRoomContent,
    [ roomDataConstants.ACTION_TYPES.ADD_WEBSOCKET_TO_QUEUE_FOR_ROOM ]:         addWebsocketToQueueForRoom,
    [ roomDataConstants.ACTION_TYPES.REMOVE_WEBSOCKET_FROM_QUEUE_FOR_ROOM ]:    removeWebsocketFromQueueForRoom,
    [ roomDataConstants.ACTION_TYPES.ADD_LOCAL_CLIENT_TO_ROOM ]:                addLocalClientToRoom,
    [ roomDataConstants.ACTION_TYPES.REMOVE_LOCAL_CLIENT_FROM_ROOM ]:           removeLocalClientFromRoom,

    [ roomDataConstants.ACTION_TYPES.UPDATE_LAST_HEARTBEAT_FOR_ROOM ]:          setLastHeartbeatCountForRoom,
    [ roomDataConstants.ACTION_TYPES.SET_HEARTBEAT_TASK_FOR_ROOM ]:             setHeartbeatTaskForRoom,
    [ roomDataConstants.ACTION_TYPES.REMOVE_HEARTBEAT_TASK_FOR_ROOM ]:          removeHeartbeatTaskForRoom,

    // sphere state actions
    [ roomDataConstants.ACTION_TYPES.ADD_SPHERE_OF_TONE_AT_POSITION_IN_ROOM ]:      addSphereOfToneAtPositionInRoom,
    [ roomDataConstants.ACTION_TYPES.SET_POSITION_FOR_SPHERE_IN_ROOM ]:             setPositionForSphereInRoom,
    [ roomDataConstants.ACTION_TYPES.SET_TONE_FOR_SPHERE_IN_ROOM ]:                 setToneForSphereInRoom,
    [ roomDataConstants.ACTION_TYPES.SET_CONNECTIONS_FOR_SPHERE_IN_ROOM ]:          setConnectionsForSphereInRoom,
    [ roomDataConstants.ACTION_TYPES.CREATE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM ]:    createHoldOnSphereForClientInRoom,
    [ roomDataConstants.ACTION_TYPES.SET_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM ]:    setHoldTimeoutTaskForSphereInRoom,
    [ roomDataConstants.ACTION_TYPES.DELETE_HOLD_TIMEOUT_TASK_FOR_SPHERE_IN_ROOM ]: deleteHoldTimeoutTaskForSphereInRoom,
    [ roomDataConstants.ACTION_TYPES.REMOVE_HOLD_ON_SPHERE_FOR_CLIENT_IN_ROOM ]:    removeHoldOnSphereForClientInRoom,
    [ roomDataConstants.ACTION_TYPES.DELETE_SPHERE_FROM_ROOM ]:                     deleteSphereFromRoom

};

export default createFilteredActionHandler( actionHandlers, initialState );


================================================
FILE: backend/src/rooms/room-names.js
================================================
/*
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

const roomNames = ["aaag","aace","aadx","aafo","aafx","aalu","aamd","aamm","aany","aaoc","aarm","aasm","aavz","aawl","aaxz","aayr","aayz","abbo","abip","abji","ablh","ablk","ablo","abod","abrn","abrw","abux","abvo","abvt","abvv","abyi","accr","acdc","acdr","aceq","acgv","achk","acjo","acnh","acod","acpe","acso","actg","acwb","acwy","acxg","adao","adfx","adga","adgo","adgw","adjg","adqo","adtg","advp","advy","adyp","aebs","aech","aefp","aehz","aejm","aemd","aemk","aenx","aeoh","aeqk","aeqm","aerd","aerf","aerj","aetn","aevs","aevv","aewp","aexv","afaq","afdf","affs","afig","afla","aflg","aflx","afqs","afrx","afux","afva","afvx","afwc","afzr","agfw","aggl","agjc","agkt","agkz","aglr","agsr","agss","agtk","agus","agvu","agxz","ahai","ahbh","ahen","ahjz","ahki","ahlk","ahmb","ahnn","ahyu","ahzn","ahzu","aiao","aibs","aibu","aicj","aidl","aigj","aiia","aikm","aima","aiml","aimr","aino","aiok","aiss","aiuo","aiut","aivn","aivy","aiwa","aixt","aizp","ajaa","ajam","ajbj","ajdn","ajdp","ajhp","ajka","ajxi","ajyp","ajzh","ajzq","akam","akdl","aked","akgr","akgt","akpo","akpz","aksr","akvz","akwp","akwq","akxc","alac","alak","alaw","alca","alcn","alfk","alie","aljw","alkg","allc","almr","aloq","alor","alpa","alqt","altq","alua","alub","alwf","alwj","alyf","amaw","amcj","amfk","amoy","ampu","amqj","amtj","amxj","andt","anet","aneu","anfm","angl","anhj","anjf","anmx","annr","anoq","anov","anqm","ansp","anyd","anzn","aobu","aofe","aogj","aogm","aohx","aoka","aonv","aopv","aoqq","aorg","aote","aoyn","apbv","apdy","aphv","apjq","apjx","apkk","apkp","apne","appd","appl","appr","aptc","apzr","aqbr","aqcc","aqcn","aqdm","aqfw","aqgp","aqgy","aqht","aqhx","aqkv","aqln","aqpf","aqpx","aqra","aqrd","aqsr","aqvu","argg","argh","ariq","arli","armq","arny","arrs","arsy","arum","aryo","arzg","arzq","asgc","asit","asiw","aske","asma","asmh","asnm","aspb","asrs","asth","asul","asvi","asvk","aszk","atbm","atds","atec","atgx","athr","atkt","atno","atph","atql","atws","auao","aufd","auhe","auja","aujp","auof","ausw","auux","auvq","avag","avas","avfs","avgv","avix","avnu","avqe","avug","avvr","avvv","avwl","avzb","awbu","awdi","awex","awfg","awhx","awir","awki","awku","awmn","awog","awpt","awrf","awrw","awsh","awvz","awze","awzx","axdh","axlp","axne","axpp","axsi","axtj","axuj","axvr","axxg","axzc","ayfj","ayfq","ayim","ayio","ayjx","aymf","aymu","ayqs","ayry","ayxm","ayzk","ayzx","azbx","azex","azfm","azlq","azlu","azqt","azsm","aztu","azvz","azxd","azxr","azyn","babg","bafj","bakq","bamh","bana","batr","bawg","bazd","bbag","bbct","bbgw","bbhn","bbkj","bbmt","bbog","bbow","bbqh","bbqi","bbqk","bbsg","bbtb","bbte","bbuz","bbwv","bbxn","bbxr","bbyp","bcab","bcae","bcay","bcde","bcfb","bcgj","bchj","bckq","bcll","bcmg","bcoj","bcoq","bcpa","bcuz","bcvw","bcwk","bcwq","bcxh","bdcd","bddt","bddv","bdgd","bdia","bdix","bdju","bdkp","bdlt","bdlu","bdqf","bdtb","bdtv","bdun","beax","bebk","bebu","bedb","bede","befw","behg","behh","beib","beke","belr","bert","bete","beus","beut","beve","beyb","beyj","bezh","bfar","bfbo","bfbx","bfdh","bfdk","bfdr","bfds","bfed","bfhc","bfjt","bfps","bfri","bfuy","bfxc","bfzu","bgak","bgbr","bgcf","bgdd","bgen","bgge","bghh","bghl","bghq","bgjb","bgrh","bgwe","bgwp","bgwr","bgwv","bgzv","bhhx","bhie","bhlz","bhrj","bhub","bhzr","bhzt","bhzy","biam","bica","bidp","biej","bifd","bigz","bilu","bimx","biqa","bitq","biuw","biwe","biwp","bjaa","bjen","bjey","bjgd","bjgk","bjhc","bjhi","bjhx","bjia","bjid","bjiq","bjkn","bjoa","bjph","bjpm","bjrd","bjri","bjrv","bjuu","bjyh","bjyu","bjzw","bkfy","bkoy","bkpr","bkqq","bksm","bkum","bkvh","bkvs","bkxi","blbz","blct","blej","blfo","blhg","bljl","blkj","blkq","bllk","blnk","blqm","blsj","blsu","bltm","bltt","blwu","blxi","blxx","blyk","blyp","blzd","bmbc","bmbe","bmdl","bmef","bmeh","bmgz","bmjr","bmly","bmmu","bmpv","bmqc","bmqm","bmrn","bmry","bmst","bmtu","bmzj","bnai","bnal","bnat","bncf","bnfd","bnfv","bnhw","bnhz","bnmo","bnnb","bnqi","bnqk","bnrg","bnrp","bnsl","bntl","bnto","bnxe","bnxm","bnzt","bobm","bokn","bolr","bonm","boov","boqb","bosd","botr","boub","bpcz","bpjn","bpkp","bpkz","bpll","bpmw","bpov","bpsl","bpwk","bpxb","bpyp","bqbs","bqbz","bqef","bqgi","bqia","bqid","bqil","bqke","bqlf","bqml","bqql","bqqp","bqqs","bqqv","bqru","bqst","bquz","bqvw","bqwm","bqws","bqxl","bqxn","bqxv","bqxw","brgy","briv","brkk","brld","brlj","brnw","bruv","brvg","brvu","bryb","bsat","bsdl","bshd","bsjy","bsll","bslw","bsmg","bsns","bsog","bsqj","bssd","bssq","bsst","bswe","btcs","btdp","btez","btfn","btgw","btid","btij","btir","btnw","btps","btpz","bttf","btuh","btxj","btzf","buao","bubm","bucf","bucj","budu","buen","buge","bugw","buhx","buiv","bulw","bumw","buoo","buop","buqy","busg","buxg","bvar","bvhx","bvii","bvkg","bvkh","bvki","bvpu","bvqa","bvxd","bvxj","bvxv","bvxx","bvzh","bwda","bwei","bwfg","bwgu","bwhz","bwko","bwkt","bwle","bwof","bwpi","bwqw","bwqx","bwto","bwtx","bwxp","bxad","bxdb","bxdj","bxep","bxfq","bxha","bxjp","bxjq","bxjr","bxkn","bxly","bxnd","bxnp","bxsz","bxxe","bxxn","bxzf","bxzm","bxzq","bybo","bycs","byjb","byju","byly","bynh","bypp","byrr","byrv","bysd","bytt","byuo","byxi","bzam","bzaq","bzbp","bzcw","bzgd","bziq","bzjo","bzkh","bzln","bzms","bznt","bzoq","bzpu","bztq","bztw","bzwl","cacc","cahy","caji","cajp","calo","cani","canp","caof","caot","capd","capx","cban","cbbs","cbbv","cbcm","cbgf","cbip","cbkj","cbmi","cbmr","cbmt","cbok","cbou","cbsq","cbwm","cbxr","cbyi","cbzj","ccav","ccbl","ccbz","cccg","cccj","ccet","ccfo","ccfq","ccgg","cchg","cchl","cchr","ccjp","cclg","ccoj","ccor","ccph","ccpq","ccrx","ccry","ccvq","ccwt","ccxb","ccxq","ccyk","cdfy","cdit","cdjq","cdjs","cdml","cdol","cdpj","cdpo","cdpw","cdra","cdrk","cdrz","cdsr","cdts","cdvf","cdvq","cdxm","cedc","cedm","cedt","cedz","cegt","cein","ceiv","cejx","ceoj","cepq","cerm","ceun","cevl","cevv","cewu","ceyj","cfbe","cfit","cfjc","cflz","cfol","cfrs","cfuh","cfxt","cfyp","cfzr","cgcg","cgch","cggt","cgjn","cgkh","cgoo","cgot","cgpv","cgqu","cgsq","cgsz","cgye","cgzl","chbz","chcy","chdd","chfo","chgm","chio","chji","chjt","chjw","chmh","chnb","chnm","chpz","chvy","chxy","chxz","chyo","cian","ciby","cidv","cihr","cijz","cimy","cinw","cjai","cjdd","cjdl","cjeu","cjfe","cjgb","cjgh","cjja","cjmt","cjsr","cjtj","cjuf","cjva","cjwo","ckbc","ckbu","ckck","ckdh","ckhm","ckjw","ckjy","ckkd","ckks","ckpk","ckpw","ckqk","ckrx","ckst","cktn","ckts","ckvg","ckxf","ckzh","clbx","clcs","clcu","clej","cleo","clgn","clhd","clnx","cloj","clox","clqf","clsu","cluu","clxe","cmbl","cmeb","cmgo","cmkn","cmlz","cmnq","cmpc","cmpo","cmta","cmuv","cmuw","cmxd","cmxe","cmyw","cngp","cnhn","cnhs","cnih","cnjj","cnnh","cnps","cnpv","cnqi","cnse","cnsm","cnul","cnxp","cnxt","codd","codg","coga","cojk","cojw","cosp","cotd","cotz","covb","covl","covm","cowg","cowj","coyx","cpan","cpay","cpbl","cpcp","cpfk","cpgl","cpgm","cpgw","cphw","cpii","cpjc","cpjl","cpls","cpmm","cpra","cpre","cprm","cpst","cqfl","cqod","cqos","cqrh","cqsm","cqve","cqvg","cqwp","cqwv","cqzp","crdr","crhp","crhz","crir","crit","crkh","crks","crqz","crsd","cruw","crxk","cryj","crzt","crzx","cscj","csdb","csfi","csfv","csgy","csis","cskc","cskv","csmi","csnd","csqn","csqw","cssg","cstd","cstl","csug","csvx","cszn","ctcv","ctgb","cthj","ctmw","ctpb","ctpd","ctpo","ctqg","ctto","cttz","ctuq","ctvj","ctvz","ctwj","ctxf","ctxh","ctxn","ctyw","ctzx","cubo","cuhb","cuhj","cuhp","cuhy","cujr","cuqe","cuqk","cutl","cuvq","cuwx","cuxy","cuzk","cuzq","cvci","cvcn","cvdj","cvdr","cvft","cvhr","cvhu","cvrr","cvtv","cvze","cwdj","cwee","cwfv","cwfw","cwma","cwmb","cwph","cwrz","cwwk","cwxw","cxbm","cxbt","cxdv","cxhv","cxkb","cxla","cxlm","cxmp","cxoa","cxpe","cxsx","cxvm","cxvp","cxzl","cyau","cybu","cycg","cydd","cydh","cydl","cydu","cygg","cyhe","cyhw","cykl","cyoi","cytc","cytr","cyxs","cyyg","cyyp","czay","czdv","czfa","czfo","czfp","cziv","czkg","czku","czle","czlx","cznd","cznx","czpw","czse","czwc","czws","czxd","czzk","czzu","czzz","dacv","danx","dapq","daqk","daqt","daso","dasp","dasv","dawl","dawv","daxc","daxd","dazj","dbbs","dbgi","dbox","dbrf","dbsd","dbvq","dbxk","dbzf","dcax","dcct","dcee","dcer","dcic","dcin","dciq","dckg","dclz","dcnd","dcok","dcoq","dcpp","dcrn","dcuh","dcul","dcvd","dcwn","dcxb","dczk","ddav","dddj","ddfc","ddjo","ddmr","ddof","ddqv","ddro","ddtb","dduu","ddyq","ddzd","debp","debs","decw","defg","degt","dehl","dejb","dely","demx","deoe","deov","deph","derc","deup","deur","deuw","dewf","dfba","dfcg","dfdn","dfgt","dfiz","dfjx","dfjy","dfkj","dfko","dfli","dfsy","dftj","dftk","dftl","dgaq","dgcq","dgfl","dgii","dgjl","dgjq","dgkg","dgmc","dgnm","dgpt","dgqt","dgsr","dgzq","dhas","dhbq","dhff","dhfh","dhfx","dhhd","dhkk","dhlq","dhlv","dhrt","dhss","dhus","dhyu","dhyy","dico","dihg","diia","dijk","diki","dikm","dimm","dior","dirg","diua","diue","diuu","dixd","dixg","djal","djbz","djfc","djfr","djfy","djhe","djie","djll","djlp","djmb","djmf","djnm","djop","djqd","djse","djsm","djuv","djvz","djwi","djyn","dkae","dkbq","dkel","dkic","dkjd","dkjv","dklq","dkmc","dknz","dksn","dksp","dkun","dkvr","dkwf","dlci","dlkm","dlky","dlmt","dlof","dlqa","dlrl","dlwt","dlyt","dlzc","dmby","dmcu","dmjw","dmmx","dmrh","dmtj","dmtk","dmui","dmum","dmuy","dmwq","dmxi","dmxp","dmxs","dnad","dnbs","dnhn","dnhw","dnic","dnjt","dnlu","dnoa","dnod","dnon","dnor","dnpb","dnph","dnqu","dnsp","dnsr","dnua","dnuz","doam","dobn","doeb","dohc","domo","donn","doqv","doue","dovc","dowx","doxz","dozc","dpbj","dpez","dpfp","dpgj","dpif","dpkv","dpoi","dprs","dpsm","dptw","dpvi","dpwd","dpwh","dpwm","dpxq","dqay","dqbk","dqbx","dqdh","dqeg","dqgl","dqhz","dqie","dqka","dqlo","dqnp","dqns","dqom","dqqc","dqrf","dqrk","dqsn","dqtd","dqtu","dquh","dqul","dqum","dqwl","dqxb","dqxp","dqzq","dqzv","drbh","drgj","drgm","drgp","drgv","drjz","drnl","drpv","drrn","drtf","dryy","drzc","drzf","drzx","dsad","dsaj","dsak","dscn","dsdy","dsey","dsfy","dsif","dsjm","dsla","dsld","dsna","dspm","dsty","dswe","dsxu","dsyt","dtej","dtfi","dtfj","dtgo","dtio","dtiu","dtji","dtjz","dtlg","dtmh","dtmk","dtml","dtwb","dtwm","dtza","duab","dudr","dueu","dugy","dulx","dunz","duom","duph","dupl","duto","duws","duxd","duyo","duys","dvad","dvck","dvev","dvip","dvlh","dvli","dvmq","dvmt","dvns","dvrv","dvvb","dvwg","dvxn","dvxo","dvyn","dwbb","dweu","dwfe","dwfh","dwfy","dwgz","dwho","dwhp","dwlf","dwol","dwqc","dwqj","dwrs","dwsf","dwsx","dwtx","dwus","dwuw","dwzr","dwzw","dxcc","dxdc","dxdd","dxdn","dxef","dxfe","dxiq","dxla","dxnp","dxoe","dxpj","dxpx","dxta","dxxk","dxzw","dyaq","dyba","dycw","dyeh","dyfb","dyix","dykq","dykv","dymt","dyoi","dyqh","dyqs","dysk","dyyn","dyzu","dzbu","dzfo","dzjy","dzlr","dzms","dznd","dzoq","dzps","dzqg","dzqy","dzrc","dzvi","dzvl","dzwd","eada","eadf","eaeq","eaez","eaff","eagf","eahw","eaio","eajs","ealq","eanh","eank","eaps","eaxv","eazg","eazz","ebaz","ebdi","ebgj","ebkd","ebkl","eblr","ebmj","ebnw","ebob","eboz","ebsh","ebzn","ecbq","echi","ecja","eckz","ecmz","ecsx","ecwj","edal","ediz","edme","edmf","edoh","edtg","edtx","edwy","edxp","eeaw","eeew","eejr","eelb","eelt","eelz","eene","eepv","eepz","eeqy","eesj","eesn","eeso","eetw","eewc","eeyn","eeys","eezm","effl","efhl","efhn","efhp","efhx","efjq","efkn","eflx","efmq","efnl","efnp","efqn","efsn","efun","egas","egeu","eghv","egik","egiv","egkp","eglw","egpb","egrb","egro","egxn","egye","ehdg","ehgg","ehhh","ehhq","ehih","ehiu","ehjq","ehnv","ehoe","ehuw","ehwk","ehyq","eiba","eicd","eict","eief","eifq","eige","eigf","eigh","eiix","eije","eikc","eils","eilz","eiok","eisv","eivn","eivv","eiwk","ejab","ejad","ejag","ejbd","ejbk","ejcn","ejdq","ejds","ejeb","ejex","ejfl","ejgz","ejjx","ejkj","ejkq","ejlc","ejne","ejos","ejtl","ejul","ejvv","ejyl","ekes","ekkh","ekku","ekla","ekml","ekne","ekrq","ekru","eksl","ekvp","ekxe","ekyl","elac","elch","elcn","elhp","elmj","elmp","elrx","elsj","eltr","elub","elxf","elyj","embq","emds","emgs","emvs","emwy","emzi","emzr","enao","endc","endi","enfy","enhz","enix","enkt","enlm","enmc","enmq","enuh","enuo","enup","enyw","eoat","eock","eocp","eocu","eoeg","eogt","eojc","eolw","eomg","eoqw","eowb","eoxy","eoyk","eozn","epao","epcs","epda","epeo","epey","epfw","epke","eplx","eppa","eppy","epre","epth","epuw","epzw","eqan","eqbg","eqcj","eqgd","eqjb","eqln","eqmt","eqmu","eqmz","eqpu","eqpz","eqra","eqxd","eqxt","eqye","eqyw","eqza","eqzx","erfo","erkt","erlj","erqf","erue","erxj","esbn","eseu","esfg","esfh","eshb","esht","esip","eslr","eslz","esnn","esnx","espz","esty","esvh","eswb","esxe","esxo","esya","etcj","etfr","etgd","etgz","ethd","etiq","etpe","etqi","etwr","eudo","euhq","euia","euiq","eumq","euny","euon","euos","eurb","eurh","eutk","euui","euul","euus","euve","euvk","euwc","euyb","euyk","euzb","euzp","euzw","evcb","evfp","evgl","evgy","evif","evlr","evnf","evqe","evrn","evza","ewae","ewat","ewbe","ewiz","ewju","ewki","ewlv","ewmi","ewnh","ewni","ewot","ewpc","ewrg","ewrz","ewxq","ewyo","ewzk","exat","exbk","excb","exeo","exfd","exfw","exgj","exin","exjg","exjt","exmd","exmh","exni","expd","exsc","exuu","exyg","eyba","eycb","eydm","eydr","eyft","eyhw","eyih","eyjf","eyll","eynu","eyoa","eypv","eytm","eyue","eyvj","eyvp","eywh","eyxo","ezee","ezkf","ezli","ezma","ezqy","ezxx","ezyj","ezyv","fadm","fahl","falb","fals","fasy","fato","fatz","faue","fazl","fbey","fbfz","fbga","fbgf","fbjj","fbju","fbol","fbon","fbps","fbqx","fbtk","fbtt","fbue","fbxz","fbzv","fcai","fcfj","fchx","fcin","fcja","fckz","fclb","fclv","fcmq","fcmt","fcos","fcss","fcta","fcux","fcvc","fcyk","fczk","fddj","fdgl","fdgv","fdhi","fdkm","fdkw","fdnd","fdnq","fdpg","fdqi","fdsy","fdwj","fdwr","fdxb","fdxx","fdzl","fecn","fedo","feek","fefw","fegd","fehi","fekg","fekp","felp","feno","feon","fers","fetb","fevj","ffbu","ffcr","ffek","ffhi","ffrb","ffsi","ffsx","ffta","ffyc","ffym","ffyx","ffzt","fgcm","fgdj","fgdt","fgjq","fglj","fglw","fgnh","fgqk","fgqn","fgsw","fgtm","fgvj","fgyo","fhak","fhax","fhca","fhcc","fhdw","fhej","fhgf","fhjf","fhkj","fhkq","fhkr","fhli","fhlz","fhmv","fhor","fhtn","fhug","fhul","fhwb","ficx","fiej","fies","figk","fiid","finf","fipr","fiqh","firt","fitt","fiuo","fivq","fiwe","fixj","fiyq","fizn","fjcb","fjdr","fjfj","fjfl","fjfo","fjgt","fjmw","fjne","fjqi","fjrr","fjrz","fjwa","fjxk","fjxz","fjyz","fkbr","fkez","fkgz","fkib","fkjs","fkkn","fkns","fkor","fkph","fkpk","fkpn","fkqx","fkrc","fkrk","fktw","fkup","fkvh","fkye","fkzl","flfw","flke","flkh","flkm","flll","flma","flmt","flmy","flrp","fltn","flvy","flwh","flyg","flzl","fmaq","fmbi","fmej","fmgs","fmin","fmjv","fmly","fmmb","fmmm","fmmn","fmnt","fmph","fmrb","fmtu","fmvd","fmxj","fnep","fngp","fnhb","fnkr","fnkz","fnla","fnme","fnmu","fnpa","fnse","fnsn","fntv","fnyn","fnyr","foax","fobe","fofx","fogq","fohr","fohy","fokr","foks","fokt","foku","folq","fopy","foqx","forc","fost","fotv","fovc","fpac","fpae","fpat","fpbs","fpdp","fpfx","fpgn","fpiu","fpjb","fpka","fpmm","fpov","fppr","fpqy","fpre","fprh","fprz","fpsh","fpsl","fqaa","fqbv","fqda","fqdo","fqfg","fqie","fqis","fqiv","fqjc","fqje","fqkx","fqme","fqnq","fqoi","fqux","fqwg","fqwy","fqxo","frbh","frbi","frcy","frey","frff","frgy","frhd","frhf","frkf","frkm","frkx","frkz","frmd","frnj","frnq","frns","frob","froh","frop","froq","frqv","frrh","frsd","frsp","frvv","frwb","frww","frxa","fryt","fryu","fsby","fscj","fsef","fshm","fsii","fsjb","fskh","fskj","fskw","fsmu","fspg","fssu","fswa","fsxi","ftbp","ftff","ftfj","ftgm","fthe","fthn","ftii","ftoa","ftrk","ftsp","ftvf","ftwg","fuas","fubo","fucx","fucy","fugl","fugt","fugy","fuhj","fukg","fukq","fulf","fuok","fusy","fuud","fuum","fuwz","fuyv","fvbe","fvcb","fvdq","fvfr","fvfu","fvgt","fvid","fvim","fvkp","fvmq","fvox","fvoz","fvpp","fvra","fvrb","fvrc","fvso","fvzo","fwad","fwap","fwca","fwdc","fwds","fwew","fwud","fwum","fwuy","fwvu","fwxj","fxdm","fxen","fxfh","fxfi","fxgt","fxsc","fxtw","fybe","fycl","fycx","fydr","fyed","fyeo","fyhx","fylt","fyoq","fyqb","fyss","fyuf","fyvz","fyyh","fzbt","fzdi","fzig","fzlo","fzmi","fznc","fzni","fznm","fzpp","fzre","fzvi","fzvm","fzyi","gaac","gaaz","gadn","gaij","gama","gamt","gaos","gaow","garj","gasq","gavk","gavq","gawg","gbac","gbdj","gbez","gbih","gbkq","gbld","gblk","gbnj","gboe","gboh","gbvg","gbwo","gbwx","gbye","gbzf","gcar","gchv","gcke","gcln","gclr","gcmk","gcpb","gcrw","gcvd","gcvt","gcwl","gczh","gdbs","gdco","gdfq","gdlc","gdnv","gdrh","gdsq","gdta","gdty","gdyn","gdyw","gebf","gecx","gedg","geew","gefz","gelr","gelz","genb","geyh","gfbi","gfde","gfdv","gfge","gfgx","gfhv","gfjh","gfjz","gflk","gfsg","gftp","gfuq","gfvt","gfwv","ggfb","gghc","gghk","gghn","gglm","ggnb","ggpp","ggqx","ggrg","ggtw","ggun","ggvi","ggwb","ggzo","ghas","ghbf","ghcc","ghda","ghdd","ghdh","ghko","ghkq","ghku","ghlu","ghmi","ghpi","ghvz","ghxu","ghys","ghzz","giai","giaj","gibx","gicl","gict","gidc","gifk","gigd","gijs","giln","gimi","ginm","gipq","giub","giux","gjfe","gjjn","gjlm","gjoa","gjol","gjoz","gjpy","gjrk","gjrw","gjtv","gjtw","gjul","gjur","gjvw","gjwk","gjxy","gjyh","gjzd","gjzp","gkcl","gkcn","gkeh","gkff","gkfh","gkgx","gkhg","gkks","gkmf","gkmg","gkpb","gkrr","gkvu","glah","glbk","glch","gldb","glep","glfe","gljh","glmj","glni","glnz","glot","gloz","glph","glwt","glyh","glyk","glyu","glzz","gmhc","gmkd","gmlh","gmmg","gmmq","gmpb","gmpc","gmpi","gmpo","gmpt","gmqg","gmtv","gmwu","gmxl","gmxz","gmyk","gmzs","gnds","gnfz","gnlr","gnmc","gnnh","gnni","gnqn","gnul","gnuy","gnvz","gnwj","gnzm","goae","goam","gobg","gobr","godk","gohg","goki","gokp","goot","gooz","gopd","gopg","gord","goth","goys","gozx","gpbb","gpcs","gpdm","gpdw","gpgt","gpkk","gpkt","gplc","gpll","gpoj","gpsc","gpuu","gpwb","gpzi","gqar","gqgw","gqiq","gqit","gqle","gqlf","gqli","gqlm","gqoj","gqpd","gqum","gqvv","gqyx","gqzb","gqzf","grah","grec","grfi","grfm","grhj","grhx","grnc","grnm","grnn","grnq","grov","grtx","gryu","grzd","grzv","gsca","gscd","gsct","gscy","gsew","gsfz","gslr","gsnf","gsqt","gsrz","gssl","gsuv","gszf","gszi","gszy","gtcz","gtdp","gtev","gtfl","gthn","gtkr","gtmt","gtnh","gtom","gtqo","gtqq","gtqv","gtrt","gtsf","gtvr","gtwf","gtxj","gtyu","gueb","gueo","gufb","gugz","guik","gumi","gumr","gupj","gurg","gusx","guti","guyh","guyu","gvao","gvcf","gvci","gvdz","gvej","gvfl","gvgu","gvij","gvjd","gvjg","gvkx","gvln","gvok","gvox","gvue","gvus","gvzp","gwhj","gwlu","gwnz","gwom","gwpw","gwss","gwtd","gwwj","gwxo","gwym","gxam","gxaz","gxbk","gxbz","gxdf","gxdu","gxgq","gxgv","gxhu","gxhw","gxii","gxio","gxjg","gxly","gxmi","gxnt","gxow","gxpn","gxpz","gxrv","gxsg","gxwk","gxww","gxxc","gxxq","gxxs","gxxw","gydn","gydv","gyfj","gyjr","gykl","gyqb","gyrf","gyrw","gytm","gywl","gyzc","gzas","gzbl","gzdj","gzea","gzir","gzkj","gzml","gznq","gzpx","gzqi","gzso","gzsz","gztm","gzxv","haah","haeo","hagw","hahm","haia","haif","hakl","hari","hase","hatk","haus","havh","hazg","hbaj","hbey","hbgb","hbgm","hbiv","hbqq","hbrq","hbtp","hbtw","hbuq","hbuu","hbxf","hcdj","hcdr","hcdx","hcef","hcgo","hchp","hcji","hckm","hcku","hcma","hcnp","hcnz","hcoa","hcom","hcos","hcxh","hcxi","hczk","hdbp","hdca","hdel","hdex","hdft","hdnp","hdrz","hdty","hdum","hdwf","hdww","heay","hecl","hedg","hehw","heih","heni","hepr","herr","hesx","hesy","heuj","hevh","hevw","hews","heyi","hfcu","hfcz","hfdi","hfdp","hfef","hffp","hfix","hfni","hfof","hfpf","hfqe","hfqi","hfrg","hftg","hfth","hfvm","hfxb","hfxf","hfyw","hfzb","hgaw","hgbj","hgea","hged","hgen","hgft","hggm","hghk","hghn","hgip","hgkg","hgkn","hgna","hgnp","hgpc","hgru","hgtu","hguu","hguz","hgwn","hgzj","hgzu","hhbm","hhbp","hhcj","hhdb","hhmb","hhmh","hhmu","hhny","hhpo","hhpp","hhqj","hhqy","hhwf","hhxe","hhyr","hhyw","hiav","hiev","higq","hihk","hikn","himg","himw","hion","hipx","hipy","hisa","hitc","hitp","hiug","hiuw","hivm","hiwr","hiyw","hjdd","hjef","hjer","hjhp","hjiq","hjke","hjkz","hjlv","hjnk","hjns","hjoi","hjql","hjqz","hjrw","hjsr","hjtk","hjwd","hjzn","hjzt","hkcq","hkdq","hkfy","hkgy","hkhh","hkkz","hklm","hknp","hknz","hkoi","hkpw","hkqw","hkra","hkrk","hkty","hkwa","hkwi","hkyp","hlbl","hlce","hlct","hlfu","hlhl","hlho","hllg","hlnb","hlpk","hlqs","hltd","hlxq","hlzn","hlzz","hmap","hmbb","hmgu","hmkh","hmow","hmti","hmto","hmuf","hmwn","hmxl","hmzj","hncv","hnez","hnie","hnjk","hnkz","hnoo","hnpn","hoad","hobh","hocm","hodv","hofb","hojs","hojy","hokz","holg","homu","honb","honp","hoom","hoph","hopj","hosd","houi","hous","hovf","hovi","hoxt","hoyr","hpbg","hpbn","hpcg","hpcl","hpgh","hphe","hpjt","hplh","hpmq","hpoj","hppz","hprv","hpse","hptu","hpwo","hpxs","hqdw","hqfd","hqfk","hqfl","hqfy","hqhy","hqla","hqlm","hqmu","hqnd","hqou","hqpz","hqqf","hqsh","hquc","hquu","hqvo","hqww","hqxk","hqzs","hrav","hrdk","hrgm","hrit","hrjs","hrkd","hrkk","hrkv","hrmb","hrqb","hrwa","hrzp","hrzw","hsfv","hsgv","hsjl","hskl","hsku","hskw","hsnc","hsnk","hspy","hsqm","hsru","hstd","hsvu","hsxx","hsze","htae","htas","htbc","htbj","hteb","hten","htey","htfr","htfs","htgy","hthw","htjf","htnt","htoj","htrc","httf","htug","htuh","htuo","htvx","htxw","huaj","hufr","hugn","hujk","husz","huui","huvm","huyp","huzb","huzt","hvae","hvco","hvec","hvek","hveo","hvho","hvjx","hvlb","hvlq","hvoa","hvoo","hvoq","hvov","hvtv","hvuf","hvus","hvxd","hvzu","hwao","hwdf","hwgd","hwhc","hwie","hwkv","hwmg","hwmv","hwoh","hwqh","hwqu","hwrj","hwsp","hwsu","hwyt","hxbc","hxcw","hxfl","hxnc","hxpm","hxpx","hxsh","hxvc","hxvy","hxwo","hxyg","hxyn","hxza","hxzs","hybz","hych","hygp","hygq","hygz","hyhn","hypm","hyrt","hysj","hyvq","hyzf","hzch","hzfu","hzjf","hzkk","hzmn","hzob","hzqz","hzrg","hzui","hzxx","hzze","hzzx","hzzz","iaap","iadf","iaff","iajg","iakw","iaom","iasl","iasx","iats","iatx","iava","iavk","iavw","iaxs","iayx","ibfl","ibiv","ibiy","ibjk","iblu","ibnd","ibnf","iboe","ibph","ibqb","ibqq","ibri","ibxk","ibxp","icar","icbp","iccw","icgb","icgs","iclp","icme","icsh","ictz","icuf","icuq","icut","iczu","idcp","idds","iddz","ided","ideh","idhg","idhq","idie","idkd","idnt","idnw","idqz","idtn","iduo","iduu","idvy","idyt","iedn","ieei","iefu","iemf","iemr","ieod","iepi","iepx","iequ","iesn","iesp","iesq","ieut","ieuz","ievd","ievj","ifbg","ifcf","ifcq","ifdq","ifej","ifft","ifht","ifiy","ifjc","ifjr","ifkr","ifni","ifob","iftt","ifui","ifxj","ifyv","ifzp","ifzq","ifzu","igaj","igbf","iged","igft","iggj","iggl","iggs","igil","igiz","igmm","igoa","igrs","iguh","igyu","igzu","ihbj","ihcf","ihfv","ihgm","ihie","ihkf","ihkg","ihnr","ihod","ihrf","ihsb","ihsg","ihsv","ihsw","ihuw","ihvm","ihwf","ihzb","iiaw","iici","iicn","iidp","iigc","iign","iijd","iikf","iikt","iiky","iile","iilq","iinf","iiom","iior","iioy","iipg","iiqt","iire","iitu","iizo","ijak","ijeb","ijeg","ijer","ijet","ijge","ijju","ijlh","ijlm","ijns","ijqj","ijrv","ijuk","ijxc","ijzr","ijzy","ikac","ikba","ikdi","ikdu","ikeb","ikgo","ikgs","ikit","ikjy","ikko","iklr","iklv","ikmo","ikuj","ikxy","ikym","ilal","ilay","ilbh","ilcj","ilkr","ilng","ilnn","ilqo","ilrk","ilrv","iltk","ilvg","ilwd","ilwf","ilyt","ilzi","imau","imav","imcm","imdb","imeh","imey","imfh","imgx","imjn","imjz","imla","imlc","imoe","imqd","imrq","imrt","imtn","imwg","imwp","imxq","imyq","imzj","indw","infm","infv","ingd","ingn","ings","inhr","inhv","inhx","iniw","inje","injv","inmh","innj","inpb","inpk","inqo","inzf","ioao","iodl","iody","iokt","iolv","iomw","ionc","iopj","iopo","iosd","ioty","ioyc","ioyz","ipew","ipfu","ipif","ipmk","ipmq","ipnv","ippc","ippd","ipqi","iprn","ipxy","ipyz","iqcy","iqex","iqfc","iqfk","iqfx","iqlr","iqns","iqri","iqrw","iqso","iqvb","iqwk","iqxp","iqxx","iqyl","iqyy","iqzy","iral","irdk","irjk","irlw","irmp","irna","iroz","irpo","irux","iryg","iryy","isaf","isao","isbz","isds","isfc","ishi","isif","isml","isoo","isqe","isqr","isrt","istg","istt","isvh","isxy","isza","iszx","itct","itfn","ithi","itip","itiw","itmp","itpf","itpl","itqa","itud","itwg","ityx","iubd","iubi","iudw","iugh","iuhx","iuie","iulz","iuoc","iupm","iuth","iuto","iuvt","iuwx","ivaf","ivbo","ivcd","ivcf","ivea","ivfy","ivhg","ivjj","ivmo","ivmp","ivok","ivrk","ivxh","ivxx","ivyb","ivys","iwek","iwfo","iwim","iwnh","iwnj","iwpo","iwth","iwum","iwvr","iwvv","iwwh","iwxe","iwxs","ixac","ixdf","ixgz","ixiy","ixlo","ixly","ixml","ixor","ixvc","ixvr","ixwz","ixyo","ixyx","iyaz","iyci","iydq","iyfj","iyfl","iylh","iyma","iymo","iynr","iypx","iyrb","iysh","iyyx","iyyy","iyza","izak","izat","izcy","izep","izfk","izjn","izoe","izoz","izqy","izrm","iztw","izut","izvk","izwg","izyi","jacx","jada","jadu","jafi","jafo","jaig","jaka","jamf","jaso","java","jazc","jazp","jbbd","jbbv","jbeb","jbfv","jbfx","jbgn","jbiu",
Download .txt
gitextract_yh_t2yfb/

├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── app.yaml
├── backend/
│   ├── .babelrc
│   ├── .gitignore
│   ├── README.md
│   ├── package.json
│   └── src/
│       ├── config.js
│       ├── datastore/
│       │   ├── datastore-constants.js
│       │   └── index.js
│       ├── index.js
│       ├── logger/
│       │   ├── cloud-logger.js
│       │   └── index.js
│       ├── messages/
│       │   ├── index.js
│       │   ├── message-actions.js
│       │   ├── message-constants.js
│       │   ├── message-handler.js
│       │   ├── message-rate-limiter.js
│       │   ├── message-sagas.js
│       │   ├── message-schema.js
│       │   └── message-validator.js
│       ├── pubsub/
│       │   ├── index.js
│       │   └── pubsub-client.js
│       ├── rooms/
│       │   ├── index.js
│       │   ├── room-data-actions.js
│       │   ├── room-data-constants.js
│       │   ├── room-data-reducer.js
│       │   ├── room-names.js
│       │   ├── room-sagas.js
│       │   ├── room-state-actions.js
│       │   ├── room-state-constants.js
│       │   ├── room-state-machine.js
│       │   ├── room-state-reducer.js
│       │   └── room-state-utils.js
│       ├── s11n.js
│       ├── server/
│       │   ├── app-server.js
│       │   ├── balancer.js
│       │   ├── index.js
│       │   ├── server-actions.js
│       │   ├── server-constants.js
│       │   ├── server-reducer.js
│       │   ├── server-sagas.js
│       │   ├── server-setup.js
│       │   ├── server-startup.js
│       │   ├── server-sync-handlers.js
│       │   ├── server-sync.js
│       │   ├── target-server-utils.js
│       │   └── websocket-server.js
│       ├── spheres/
│       │   ├── index.js
│       │   ├── sphere-constants.js
│       │   └── trees/
│       │       ├── 0.js
│       │       ├── 1.js
│       │       ├── 2.js
│       │       ├── 3.js
│       │       ├── 4.js
│       │       └── 5.js
│       ├── store.js
│       └── utils/
│           ├── index.js
│           ├── reducer-utils.js
│           ├── saga-utils.js
│           ├── string-utils.js
│           ├── url-utils.js
│           └── websocket-utils.js
├── config.js
├── config.template
├── index.html
├── js/
│   ├── ascene.js
│   ├── components/
│   │   ├── background-objects.js
│   │   ├── ball.js
│   │   ├── bg-tree-ring-material.js
│   │   ├── clicker.js
│   │   ├── controller-material.js
│   │   ├── controllers.js
│   │   ├── copresence-server-messages.js
│   │   ├── copresence-server.js
│   │   ├── daydream-manager.js
│   │   ├── daydream-pointer.js
│   │   ├── fake-light.js
│   │   ├── ga.js
│   │   ├── gaze.js
│   │   ├── grab-move.js
│   │   ├── haptics.js
│   │   ├── headset-material.js
│   │   ├── listener.js
│   │   ├── palette.js
│   │   ├── proximity-check.js
│   │   ├── quaternion.js
│   │   ├── smooth-motion.js
│   │   ├── teleport.js
│   │   ├── tone.js
│   │   ├── tool-tips.js
│   │   ├── touch-color.js
│   │   ├── tree.js
│   │   └── wasd-boundaries.js
│   ├── core/
│   │   ├── color-set.js
│   │   ├── colors.js
│   │   ├── instrument.js
│   │   ├── instruments.js
│   │   ├── shape-data.js
│   │   └── shapes.js
│   ├── index.js
│   ├── notes/
│   │   ├── note-head-cube.js
│   │   ├── note-head-sphere.js
│   │   ├── note-head-tetra.js
│   │   ├── note-head.js
│   │   ├── note-shadow-cube.js
│   │   ├── note-shadow-sphere.js
│   │   ├── note-shadow-tetra.js
│   │   ├── note-shadow.js
│   │   ├── note-stem.js
│   │   └── note.js
│   ├── orientation-arm-model.js
│   ├── shaders/
│   │   ├── ball-shader.js
│   │   ├── bg-tree-shader.js
│   │   ├── circle-shader.js
│   │   └── shader-chunks.js
│   ├── splash.js
│   ├── util/
│   │   ├── browserCheck.js
│   │   ├── random-range-1d.js
│   │   ├── random-range-3d.js
│   │   ├── tetrahedron.js
│   │   └── trace.js
│   └── util.js
├── package.json
├── python/
│   ├── __init__.py
│   ├── base/
│   │   ├── __init__.py
│   │   ├── api_fixer.py
│   │   ├── api_fixer_test.py
│   │   ├── constants.py
│   │   ├── handlers.py
│   │   ├── handlers_test.py
│   │   ├── models.py
│   │   ├── models_test.py
│   │   ├── xsrf.py
│   │   └── xsrf_test.py
│   ├── country_servers.py
│   ├── handlers.py
│   ├── main.py
│   └── main_test.py
├── static/
│   ├── audio/
│   │   ├── bg.ogg
│   │   ├── marimba/
│   │   │   ├── 0.ogg
│   │   │   ├── 1.ogg
│   │   │   ├── 2.ogg
│   │   │   ├── 3.ogg
│   │   │   ├── 4.ogg
│   │   │   └── 5.ogg
│   │   ├── percussion/
│   │   │   ├── 0.ogg
│   │   │   ├── 1.ogg
│   │   │   ├── 2.ogg
│   │   │   ├── 3.ogg
│   │   │   ├── 4.ogg
│   │   │   └── 5.ogg
│   │   └── voice/
│   │       ├── 0.ogg
│   │       ├── 1.ogg
│   │       ├── 2.ogg
│   │       ├── 3.ogg
│   │       ├── 4.ogg
│   │       └── 5.ogg
│   └── models/
│       ├── bg-tree-1.obj
│       ├── bg-tree-2.obj
│       ├── bg-tree-3.obj
│       ├── bg-tree-ring.obj
│       ├── controller.dae
│       ├── door-frame.obj
│       ├── door-glow.obj
│       ├── door.dae
│       ├── headset.dae
│       ├── stump-shadow.obj
│       ├── stump.obj
│       └── target.obj
├── style/
│   └── splash.scss
└── third_party/
    └── aframe-daydream-controller-component/
        ├── LICENSE
        ├── METADATA
        └── daydream-controller.js
Download .txt
SYMBOL INDEX (376 symbols across 50 files)

FILE: backend/src/config.js
  constant PROJECT_ID (line 43) | const PROJECT_ID = process.env.PROJECT_ID;
  constant LOCAL_IP_ADDRESS (line 52) | const LOCAL_IP_ADDRESS = process.env.LOCAL_IP_ADDRESS;
  constant ENVIRONMENT_NAME (line 61) | const ENVIRONMENT_NAME = process.env.ENVIRONMENT_NAME;
  constant SERVER_ID (line 69) | const SERVER_ID             = uuid();
  constant SHORT_SERVER_ID (line 70) | const SHORT_SERVER_ID       = SERVER_ID.split( '-' ).slice(0, 2).join( '...
  constant LOG_DATE_FORMAT (line 71) | const LOG_DATE_FORMAT       = "yyyymmdd|HH:MM:ss.l";
  constant SYNC_TOPIC_NAME (line 85) | const SYNC_TOPIC_NAME = formatStringAsGcpResourceName(
  constant LOG_TO_CLOUD (line 91) | const LOG_TO_CLOUD          = process.env.LOG_TO_CLOUD === 'true' ? true...
  constant WS_SERVER_PORT (line 98) | const WS_SERVER_PORT        = process.env.WS_SERVER_PORT        ? Number...
  constant WS_BALANCER_PORT (line 99) | const WS_BALANCER_PORT      = process.env.WS_BALANCER_PORT      ? Number...
  constant MAX_CLIENTS_PER_ROOM (line 100) | const MAX_CLIENTS_PER_ROOM  = process.env.MAX_CLIENTS_PER_ROOM  ? Number...
  constant LOCAL_IP_PORT_STRING (line 103) | const LOCAL_IP_PORT_STRING  = `${LOCAL_IP_ADDRESS}:${WS_SERVER_PORT}`;
  constant USE_SSL (line 111) | const USE_SSL = process.env.USE_SSL === "true" ? true : false;
  constant SSL_INFO (line 114) | let SSL_INFO = {
  constant HT_3DOF (line 140) | const HT_3DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
  constant HT_6DOF (line 141) | const HT_6DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
  constant HT_VIEWER (line 142) | const HT_VIEWER = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;
  constant HEADSET_RULES (line 146) | const HEADSET_RULES = {
  constant TIMEOUT_SPHERE_HOLDS (line 163) | const TIMEOUT_SPHERE_HOLDS = process.env.TIMEOUT_SPHERE_HOLDS === 'false...
  constant DROP_VIEWER_MESSAGES_IN_PRODUCTION (line 169) | const DROP_VIEWER_MESSAGES_IN_PRODUCTION = process.env.DROP_VIEWER_MESSA...
  constant PER_CLIENT_RATE_LIMIT (line 175) | const PER_CLIENT_RATE_LIMIT         = process.env.PER_CLIENT_RATE_LIMIT ...
  constant PER_MESSAGE_TYPE_RATE_LIMIT (line 176) | const PER_MESSAGE_TYPE_RATE_LIMIT   = process.env.PER_MESSAGE_TYPE_RATE_...
  constant RATE_LIMIT_INFO (line 178) | const RATE_LIMIT_INFO   = {
  constant PRODUCTION_ENVIRONMENT_PROJECT_ID (line 193) | const PRODUCTION_ENVIRONMENT_PROJECT_ID         = '<your-production-proj...
  constant PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN (line 194) | const PRODUCTION_ENVIRONMENT_REQUIRED_ORIGIN    = '<your-production-fron...

FILE: backend/src/datastore/datastore-constants.js
  constant DATASTORE (line 17) | const DATASTORE = {

FILE: backend/src/messages/message-constants.js
  constant INCOMING_MESSAGE_TYPES (line 43) | const INCOMING_MESSAGE_TYPES = {
  constant INCOMING_MESSAGE_COMPONENTS (line 58) | const INCOMING_MESSAGE_COMPONENTS = {
  constant OUTGOING_MESSAGE_TYPES (line 113) | const OUTGOING_MESSAGE_TYPES = {
  constant OUTGOING_MESSAGE_COMPONENTS (line 161) | const OUTGOING_MESSAGE_COMPONENTS = {
  constant HEADSET_TYPES (line 270) | const HEADSET_TYPES = {
  constant ERROR_TYPES (line 276) | const ERROR_TYPES = {

FILE: backend/src/rooms/room-data-constants.js
  constant ACTION_TYPES (line 19) | const ACTION_TYPES = {
  constant CLIENT_INFO (line 52) | const CLIENT_INFO = {
  constant ERROR_TYPES (line 67) | const ERROR_TYPES = {
  constant ROOM_INFO (line 74) | const ROOM_INFO = {
  constant SPHERE_INFO (line 79) | const SPHERE_INFO = {

FILE: backend/src/rooms/room-data-reducer.js
  constant HT_3DOF (line 30) | const HT_3DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_3DOF;
  constant HT_6DOF (line 31) | const HT_6DOF   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_6DOF;
  constant HT_VIEW (line 32) | const HT_VIEW   = messageConstants.HEADSET_TYPES.HEADSET_TYPE_VIEWER;
  constant CONNECTIONS_LABEL (line 34) | const CONNECTIONS_LABEL = messageConstants.OUTGOING_MESSAGE_COMPONENTS.R...
  constant POSITION_LABEL (line 35) | const POSITION_LABEL    = messageConstants.OUTGOING_MESSAGE_COMPONENTS.R...
  constant TONE_LABEL (line 36) | const TONE_LABEL    = messageConstants.OUTGOING_MESSAGE_COMPONENTS.ROOM_...
  function dist (line 93) | function dist(a, b){
  function randomPos (line 99) | function randomPos(){

FILE: backend/src/rooms/room-state-constants.js
  constant MODULE_NAME (line 17) | const MODULE_NAME   = 'room-state';
  constant EVENT_TYPES (line 19) | const EVENT_TYPES = ( () => {
  constant STATES (line 64) | const STATES = ( () => {

FILE: backend/src/server/server-constants.js
  constant ACTION_TYPES (line 17) | const ACTION_TYPES = {
  constant SYNC_INFO (line 46) | const SYNC_INFO = {
  constant SYNC_MESSAGES (line 53) | const SYNC_MESSAGES =  {
  constant SAVE_INFO (line 62) | const SAVE_INFO = {
  constant CUSTOM_PROXY_HEADERS (line 67) | const CUSTOM_PROXY_HEADERS = {
  constant ERROR_TYPES (line 76) | const ERROR_TYPES = {

FILE: backend/src/server/websocket-server.js
  constant CUSTOM_PROXY_HEADERS (line 47) | const CUSTOM_PROXY_HEADERS  = serverConstants.CUSTOM_PROXY_HEADERS;

FILE: backend/src/spheres/sphere-constants.js
  constant SPHERE_INFO (line 17) | const SPHERE_INFO = {

FILE: js/components/background-objects.js
  constant DEG2RAD (line 17) | const DEG2RAD = Math.PI / 180;
  constant HALFPI (line 18) | const HALFPI = Math.PI / 2;
  constant VECTOR_ZERO (line 20) | const VECTOR_ZERO     = new THREE.Vector3( 0, 0, 0 );
  constant SHADOW_X_AXIS (line 21) | const SHADOW_X_AXIS   = new THREE.Vector3( 1, 0, 0 );
  constant SHADOW_Z_AXIS (line 22) | const SHADOW_Z_AXIS   = new THREE.Vector3( 0, 1, 0 );
  constant SHADOW_LENGTH (line 23) | const SHADOW_LENGTH   = 20;
  constant SHADOW_GEOMETRY (line 24) | const SHADOW_GEOMETRY = new THREE.PlaneGeometry( 0.25, 1 );
  constant SHADOW_MATERIAL (line 25) | const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({

FILE: js/components/copresence-server-messages.js
  class Message (line 22) | class Message {
    method constructor (line 23) | constructor(type){
    method serialize (line 28) | serialize(){
  class ExitRoom (line 38) | class ExitRoom extends  Message {
    method constructor (line 39) | constructor(){
  class RoomClientPositionUpdate (line 45) | class RoomClientPositionUpdate extends Message {
    method constructor (line 46) | constructor(playerData, spheresData){
  class SpherePositionUpdate (line 74) | class SpherePositionUpdate extends Message {
    method constructor (line 75) | constructor(uuid, position){
  class SphereToneUpdate (line 82) | class SphereToneUpdate extends Message {
    method constructor (line 83) | constructor(uuid, tone){
  class SphereConnectionUpdate (line 90) | class SphereConnectionUpdate extends Message {
    method constructor (line 91) | constructor(uuid, connections){
  class GrabSphere (line 98) | class GrabSphere extends Message {
    method constructor (line 99) | constructor(uuid){
  class ReleaseSphere (line 105) | class ReleaseSphere extends Message {
    method constructor (line 106) | constructor(uuid){
  class StrikeSphere (line 112) | class StrikeSphere extends Message {
    method constructor (line 113) | constructor(uuid, velocity=1){
  class DeleteSphere (line 120) | class DeleteSphere extends Message {
    method constructor (line 121) | constructor(uuid){
  class CreateSphereAtPosition (line 128) | class CreateSphereAtPosition extends Message {
    method constructor (line 129) | constructor(position, tone=1){

FILE: js/components/copresence-server.js
  constant MESSAGE_THROTTLE_INTERVAL (line 30) | const MESSAGE_THROTTLE_INTERVAL = 200;

FILE: js/components/gaze.js
  constant BALL_RADIUS (line 18) | let BALL_RADIUS = 0.05;
  method getScale (line 173) | getScale(entity) {
  method getShape (line 176) | getShape(entity) {

FILE: js/components/grab-move.js
  constant BALL_RADIUS (line 27) | const BALL_RADIUS = 0.05;
  constant CONTROLLER_BALL_RADIUS (line 28) | const CONTROLLER_BALL_RADIUS = 0.04;
  constant DELETE_VELOCITY (line 29) | const DELETE_VELOCITY = 0.2;
  constant VELOCITY_SAMPLES (line 30) | const VELOCITY_SAMPLES = 10;
  method getTone (line 331) | getTone(el){
  method getTotalTones (line 334) | getTotalTones(el){
  method getTonesInScale (line 337) | getTonesInScale() {
  method getScale (line 340) | getScale(entity) {
  method getShape (line 343) | getShape(entity) {

FILE: js/components/listener.js
  method init (line 18) | init() {}
  method remove (line 19) | remove() {}
  method tick (line 20) | tick() {

FILE: js/components/palette.js
  constant SPHERE_BASE_URL (line 19) | const SPHERE_BASE_URL = './static/img/ball_';
  constant SPHERE_BASE_EXT (line 20) | const SPHERE_BASE_EXT = '.png';
  method update (line 28) | update() {
  method trigger (line 38) | trigger(time, tone, velocity, x, y, z) {
  method totalNotes (line 42) | totalNotes() {
  method noteCount (line 46) | noteCount() {
  method colorPalette (line 50) | colorPalette() {
  method controllerColors (line 54) | controllerColors() {
  method shapePalette (line 58) | shapePalette(id) {
  method loadSphereTextures (line 62) | loadSphereTextures() {
  method textureSprite134 (line 91) | textureSprite134(id) {
  method textureSprite567 (line 95) | textureSprite567(id) {

FILE: js/components/smooth-motion.js
  function LowpassFilter (line 18) | function LowpassFilter(Fc) {

FILE: js/components/tone.js
  method init (line 49) | init() {
  method remove (line 60) | remove() {
  method update (line 63) | update(oldData) {
  method getPosition (line 68) | getPosition() {
  method trigger (line 75) | trigger(time = Tone.now(), velocity = 1) {
  method hit (line 80) | hit(time, velocity, controllerPosition = null, sourceId = null) {
  method updatePosition (line 90) | updatePosition() {
  method controllerHit (line 98) | controllerHit(data) {
  method getShape (line 108) | getShape(){
  method getNote (line 112) | getNote(){
  method getTotalTones (line 117) | getTotalTones(){

FILE: js/components/touch-color.js
  function tweenUpdate (line 275) | function tweenUpdate() {

FILE: js/core/color-set.js
  class ColorSet (line 15) | class ColorSet {
    method constructor (line 17) | constructor( background, idle, active, stem ) {
    method setUniforms (line 24) | setUniforms( material ) {

FILE: js/core/instrument.js
  class Instrument (line 18) | class Instrument {
    method constructor (line 20) | constructor( data ) {
    method trigger (line 37) | trigger(time, tone, velocity, x, y, z){
    method _createPanner (line 45) | _createPanner(x, y, z){
    method _createSource (line 53) | _createSource(time, note, velocity){
    method dispose (line 61) | dispose(){

FILE: js/core/shape-data.js
  constant TONE_CHANNEL_MAP (line 15) | const TONE_CHANNEL_MAP = [
  class ShapeData (line 25) | class ShapeData {
    method constructor (line 27) | constructor( layout, repeats, tone ) {
    method setUniforms (line 33) | setUniforms( material ) {

FILE: js/notes/note-head-cube.js
  constant CUBE_GEOMETRY (line 18) | const CUBE_GEOMETRY = new THREE.BoxGeometry( 0.1, 0.1, 0.1 );
  constant RANDOM_ROTATION (line 19) | const RANDOM_ROTATION = new RandomRange3D(
  class NoteHeadCube (line 24) | class NoteHeadCube extends NoteHead {
    method geometry (line 26) | get geometry() {
    method rotation (line 30) | get rotation() {

FILE: js/notes/note-head-sphere.js
  constant CUBE_GEOMETRY (line 18) | const CUBE_GEOMETRY = new THREE.IcosahedronGeometry( 0.05, 2 );
  constant RANDOM_ROTATION (line 19) | const RANDOM_ROTATION = new RandomRange3D(
  class NoteHeadSphere (line 24) | class NoteHeadSphere extends NoteHead {
    method geometry (line 26) | get geometry() {
    method rotation (line 30) | get rotation() {

FILE: js/notes/note-head-tetra.js
  constant TETRA_GEOMETRY (line 19) | const TETRA_GEOMETRY = new Tetrahedron().geometry;
  constant RANDOM_ROTATION (line 20) | const RANDOM_ROTATION = new RandomRange3D(
  class NoteHeadTetra (line 25) | class NoteHeadTetra extends NoteHead {
    method constructor (line 27) | constructor( palette, shape ) {
    method geometry (line 35) | get geometry() {
    method rotation (line 39) | get rotation() {

FILE: js/notes/note-head.js
  class NoteHead (line 18) | class NoteHead {
    method constructor (line 20) | constructor( palette, shape ) {
    method setTone (line 35) | setTone( value ) {
    method highlight (line 57) | highlight( event ) {
    method unHighlight (line 60) | unHighlight( event ) {
    method hit (line 63) | hit( event, timeMs ) {
    method updateHitTween (line 103) | updateHitTween( t ) {
    method geometry (line 114) | get geometry() { }
    method rotation (line 115) | get rotation() { }

FILE: js/notes/note-shadow-cube.js
  constant SHADOW_MATERIAL (line 18) | const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({
  class NoteShadowCube (line 22) | class NoteShadowCube extends NoteShadow {
    method update (line 24) | update() {
    method geometry (line 29) | get geometry() {
    method material (line 36) | get material() {

FILE: js/notes/note-shadow-sphere.js
  constant SHADOW_GEOMETRY (line 17) | const SHADOW_GEOMETRY = new THREE.PlaneGeometry( 0.1, 0.1 );
  constant SHADOW_SHADER (line 18) | const SHADOW_SHADER = THREE.CircleShader;
  constant SHADOW_UNIFORMS (line 19) | const SHADOW_UNIFORMS = THREE.UniformsUtils.clone( SHADOW_SHADER.uniform...
  constant SHADOW_MATERIAL (line 20) | const SHADOW_MATERIAL = new THREE.ShaderMaterial({
  class NoteShadowSphere (line 26) | class NoteShadowSphere extends NoteShadow {
    method constructor (line 28) | constructor( headMesh ) {
    method geometry (line 34) | get geometry() {
    method material (line 38) | get material() {

FILE: js/notes/note-shadow-tetra.js
  constant TETRA_GEOMETRY (line 19) | const TETRA_GEOMETRY = new Tetrahedron().geometry;
  constant SHADOW_MATERIAL (line 20) | const SHADOW_MATERIAL = new THREE.MeshBasicMaterial({
  class NoteShadowTetra (line 24) | class NoteShadowTetra extends NoteShadow {
    method update (line 26) | update() {
    method geometry (line 31) | get geometry() {
    method material (line 37) | get material() {

FILE: js/notes/note-shadow.js
  constant SHADOW_SIZE (line 15) | const SHADOW_SIZE = 1.05;
  constant SHADOW_GROUND_CLEARANCE (line 16) | const SHADOW_GROUND_CLEARANCE = 0.01;
  class NoteShadow (line 18) | class NoteShadow {
    method constructor (line 20) | constructor( headMesh, rotation ) {
    method update (line 28) | update() {
    method geometry (line 36) | get geometry() { }
    method material (line 37) | get material() { }

FILE: js/notes/note-stem.js
  constant STEM_GEOMETRY (line 15) | const STEM_GEOMETRY = new THREE.CylinderGeometry( 0.005, 0.01, 1, 5 );
  class NoteStem (line 17) | class NoteStem {
    method constructor (line 19) | constructor( palette ) {
    method setTone (line 30) | setTone( value ) {
    method update (line 36) | update() {
    method hit (line 43) | hit() {

FILE: js/notes/note.js
  constant HIT_ANIM_MS (line 22) | const HIT_ANIM_MS = 750;
  constant SHAPE_NAMES (line 24) | const SHAPE_NAMES = [ 'sphere', 'cube', 'tetra' ];
  constant HEAD_CONSTRUCTORS (line 26) | const HEAD_CONSTRUCTORS = {
  constant SHADOW_CONSTRUCTORS (line 32) | const SHADOW_CONSTRUCTORS = {
  class Note (line 38) | class Note {
    method constructor (line 40) | constructor( palette ) {
    method setTone (line 45) | setTone( value ) {
    method setRotationIncrement (line 75) | setRotationIncrement(isClockwise=false) {
    method removeShadow (line 87) | removeShadow(){
    method tick (line 94) | tick() {
    method highlight (line 100) | highlight( event ) {
    method unHighlight (line 103) | unHighlight( event ) {
    method hit (line 107) | hit( event ) {
    method shape (line 111) | get shape() {

FILE: js/orientation-arm-model.js
  constant HEAD_ELBOW_OFFSET (line 16) | const HEAD_ELBOW_OFFSET = new THREE.Vector3(0.155, -0.465, -0.15);
  constant ELBOW_WRIST_OFFSET (line 17) | const ELBOW_WRIST_OFFSET = new THREE.Vector3(0, 0, -0.25);
  constant WRIST_CONTROLLER_OFFSET (line 18) | const WRIST_CONTROLLER_OFFSET = new THREE.Vector3(0, 0, 0.05);
  constant ARM_EXTENSION_OFFSET (line 19) | const ARM_EXTENSION_OFFSET = new THREE.Vector3(-0.08, 0.14, 0.08);
  constant ELBOW_BEND_RATIO (line 21) | const ELBOW_BEND_RATIO = 0.4;
  constant EXTENSION_RATIO_WEIGHT (line 22) | const EXTENSION_RATIO_WEIGHT = 0.4;
  constant MIN_ANGULAR_SPEED (line 24) | const MIN_ANGULAR_SPEED = 0.61;
  class OrientationArmModel (line 32) | class OrientationArmModel {
    method constructor (line 33) | constructor() {
    method setControllerOrientation (line 67) | setControllerOrientation(quaternion) {
    method setHeadOrientation (line 72) | setHeadOrientation(quaternion) {
    method setHeadPosition (line 77) | setHeadPosition(position) {
    method setLeftHanded (line 81) | setLeftHanded(isLeftHanded) {
    method update (line 89) | update() {
    method getPose (line 176) | getPose() {
    method getForearmLength (line 183) | getForearmLength() {
    method getElbowPosition (line 187) | getElbowPosition() {
    method getWristPosition (line 192) | getWristPosition() {
    method getHeadYawOrientation_ (line 197) | getHeadYawOrientation_() {
    method clamp_ (line 205) | clamp_(value, min, max) {
    method quatAngle_ (line 209) | quatAngle_(q1, q2) {

FILE: js/splash.js
  method init (line 32) | init(){
  method play (line 39) | play(){
  method _progressBar (line 84) | _progressBar(){
  method _allLoaded (line 102) | _allLoaded(){
  method _animateToTree (line 138) | _animateToTree(){
  method _exited (line 182) | _exited(){
  method _entered (line 189) | _entered(mode){
  method _setSceneParameters (line 231) | _setSceneParameters(params){
  method removeCardBoardInstructions (line 294) | removeCardBoardInstructions(){
  method addCardBoardInstructions (line 301) | addCardBoardInstructions(){
  method isTabletLikeDimensions (line 334) | isTabletLikeDimensions() {
  method isTouchDevice (line 339) | isTouchDevice() {
  function tweenUpdate (line 344) | function tweenUpdate() {

FILE: js/util.js
  function getParameterByName (line 15) | function getParameterByName(name, url) {
  function scale (line 27) | function scale(value, inMin, inMax, outMin, outMax) {
  function getViewerType (line 31) | function getViewerType(callBack){
  function showErrorMessage (line 51) | function showErrorMessage(imgURL, errorCode, cta, ctaUrl){

FILE: js/util/random-range-1d.js
  class RandomRange (line 15) | class RandomRange {
    method constructor (line 17) | constructor( min, max ) {
    method [ 'value' ] (line 22) | get [ 'value' ]() {

FILE: js/util/random-range-3d.js
  class RandomRange3D (line 17) | class RandomRange3D {
    method constructor (line 19) | constructor( min, max ) {
    method x (line 28) | get x() { return this.range3D.x.value; }
    method y (line 29) | get y() { return this.range3D.y.value; }
    method z (line 30) | get z() { return this.range3D.z.value; }
    method [ 'value' ] (line 32) | get [ 'value' ]() {

FILE: js/util/tetrahedron.js
  class Tetrahedron (line 40) | class Tetrahedron {
    method constructor (line 42) | constructor() {

FILE: python/base/api_fixer.py
  class ApiSecurityException (line 30) | class ApiSecurityException(Exception):
  function FindArgumentIndex (line 35) | def FindArgumentIndex(function, argument):
  function GetDefaultArgument (line 40) | def GetDefaultArgument(function, argument):
  function ReplaceDefaultArgument (line 50) | def ReplaceDefaultArgument(function, argument, replacement):
  class _JsonEncoderForHtml (line 71) | class _JsonEncoderForHtml(json.JSONEncoder):
    method encode (line 73) | def encode(self, o):
    method iterencode (line 79) | def iterencode(self, o, _one_shot=False):
  class RestrictedUnpickler (line 117) | class RestrictedUnpickler(pickle.Unpickler):
    method find_class (line 119) | def find_class(self, module_name, name):
  function _SafePickleLoad (line 125) | def _SafePickleLoad(f):
  function _SafePickleLoads (line 128) | def _SafePickleLoads(string):
  function _HttpUrlLoggingWrapper (line 153) | def _HttpUrlLoggingWrapper(func):

FILE: python/base/api_fixer_test.py
  class BadPickle (line 24) | class BadPickle(object):
    method __reduce__ (line 26) | def __reduce__(self):
  class ApiFixerTest (line 30) | class ApiFixerTest(unittest2.TestCase):
    method testJsonEscaping (line 33) | def testJsonEscaping(self):
    method testYamlLoading (line 37) | def testYamlLoading(self):
    method testPickle (line 45) | def testPickle(self):

FILE: python/base/constants.py
  function _IsDevAppServer (line 19) | def _IsDevAppServer():

FILE: python/base/handlers.py
  function requires_auth (line 45) | def requires_auth(f):
  function requires_admin (line 56) | def requires_admin(f):
  function xsrf_protected (line 67) | def xsrf_protected(f):
  function _GetXsrfKey (line 81) | def _GetXsrfKey():
  function _GetCspNonce (line 92) | def _GetCspNonce():
  class SecurityError (line 133) | class SecurityError(Exception):
  class _HandlerMeta (line 137) | class _HandlerMeta(abc.ABCMeta):
    method __new__ (line 150) | def __new__(mcs, name, bases, dct):
  class BaseHandler (line 159) | class BaseHandler(webapp2.RequestHandler):
    method __init__ (line 164) | def __init__(self, request, response):
    method _ReplacementWrite (line 189) | def _ReplacementWrite(*args, **kwargs):
    method _SetCommonResponseHeaders (line 193) | def _SetCommonResponseHeaders(self):
    method current_user (line 231) | def current_user(self):
    method dispatch (line 234) | def dispatch(self):
    method get_jinja2_config (line 240) | def get_jinja2_config(cls):
    method j2_factory (line 260) | def j2_factory(app):
    method jinja2 (line 269) | def jinja2(self):
    method render_to_string (line 276) | def render_to_string(self, template, template_values=None):
    method render (line 295) | def render(self, template, template_values=None):
  class BaseCronHandler (line 301) | class BaseCronHandler(BaseHandler):
    method dispatch (line 312) | def dispatch(self):
  class BaseTaskHandler (line 320) | class BaseTaskHandler(BaseHandler):
    method dispatch (line 331) | def dispatch(self):
  class BaseAjaxHandler (line 339) | class BaseAjaxHandler(BaseHandler):
    method _SetAjaxResponseHeaders (line 348) | def _SetAjaxResponseHeaders(self):
    method dispatch (line 352) | def dispatch(self):
    method render (line 358) | def render(self, *args, **kwargs):
    method render_json (line 361) | def render_json(self, obj):
  class AuthenticatedHandler (line 365) | class AuthenticatedHandler(BaseHandler):
    method dispatch (line 380) | def dispatch(self):
    method _RequestContainsValidXsrfToken (line 383) | def _RequestContainsValidXsrfToken(self):
    method DenyAccess (line 398) | def DenyAccess(self):
    method XsrfFail (line 402) | def XsrfFail(self):
  class AuthenticatedAjaxHandler (line 406) | class AuthenticatedAjaxHandler(BaseAjaxHandler):
    method dispatch (line 424) | def dispatch(self):
    method _RequestContainsValidXsrfToken (line 427) | def _RequestContainsValidXsrfToken(self):
    method DenyAccess (line 442) | def DenyAccess(self):
    method XsrfFail (line 446) | def XsrfFail(self):
  class AdminHandler (line 450) | class AdminHandler(AuthenticatedHandler):
    method dispatch (line 467) | def dispatch(self):
  class AdminAjaxHandler (line 471) | class AdminAjaxHandler(AuthenticatedAjaxHandler):
    method dispatch (line 491) | def dispatch(self):

FILE: python/base/handlers_test.py
  class DummyHandler (line 26) | class DummyHandler(handlers.AuthenticatedHandler):
    method get (line 29) | def get(self):
    method post (line 32) | def post(self):
    method DenyAccess (line 35) | def DenyAccess(self):
    method XsrfFail (line 38) | def XsrfFail(self):
  class DummyAjaxHandler (line 42) | class DummyAjaxHandler(handlers.BaseAjaxHandler):
    method get (line 45) | def get(self):
    method post (line 48) | def post(self):
  class DummyCronHandler (line 52) | class DummyCronHandler(handlers.BaseCronHandler):
    method get (line 55) | def get(self):
  class DummyTaskHandler (line 59) | class DummyTaskHandler(handlers.BaseTaskHandler):
    method get (line 62) | def get(self):
  class HandlersTest (line 66) | class HandlersTest(unittest2.TestCase):
    method setUp (line 69) | def setUp(self):
    method _FakeLogin (line 79) | def _FakeLogin(self):
    method testHandlerCannotOverrideFinalMethods (line 86) | def testHandlerCannotOverrideFinalMethods(self):
    method testAuthenticatedHandlerRequiresUser (line 99) | def testAuthenticatedHandlerRequiresUser(self):
    method testXsrfProtectionFailsWithInvalidToken (line 107) | def testXsrfProtectionFailsWithInvalidToken(self):
    method testXsrfProtectionSucceedsWithValidToken (line 113) | def testXsrfProtectionSucceedsWithValidToken(self):
    method testResponseHasStrictCSP (line 123) | def testResponseHasStrictCSP(self):
    method testAjaxGetResponsesIncludeXssiPrefix (line 142) | def testAjaxGetResponsesIncludeXssiPrefix(self):
    method testAjaxPostResponsesLackXssiPrefix (line 145) | def testAjaxPostResponsesLackXssiPrefix(self):
    method testCronFailsWithoutXAppEngineCron (line 148) | def testCronFailsWithoutXAppEngineCron(self):
    method testCronSucceedsWithXAppEngineCron (line 157) | def testCronSucceedsWithXAppEngineCron(self):
    method testTaskFailsWithoutXAppEngineQueueName (line 163) | def testTaskFailsWithoutXAppEngineQueueName(self):
    method testTaskSucceedsWithXAppEngineQueueName (line 172) | def testTaskSucceedsWithXAppEngineQueueName(self):

FILE: python/base/models.py
  function GetApplicationConfiguration (line 21) | def GetApplicationConfiguration():
  class Config (line 32) | class Config(ndb.Model):

FILE: python/base/models_test.py
  class ModelsTest (line 23) | class ModelsTest(unittest2.TestCase):
    method setUp (line 26) | def setUp(self):
    method testConfigurationAutomaticallyGenerated (line 31) | def testConfigurationAutomaticallyGenerated(self):

FILE: python/base/xsrf.py
  function _Compare (line 24) | def _Compare(a, b):
  function GenerateToken (line 35) | def GenerateToken(key, user, action='*', now=None):
  function ValidateToken (line 43) | def ValidateToken(key, user, token, action='*', max_age=DEFAULT_TIMEOUT_):

FILE: python/base/xsrf_test.py
  class XsrfTest (line 23) | class XsrfTest(unittest2.TestCase):
    method setUp (line 26) | def setUp(self):
    method testCompare (line 30) | def testCompare(self):
    method testTokenWithNoActionVerifies (line 35) | def testTokenWithNoActionVerifies(self):
    method testTokenWithDifferentActionsFail (line 39) | def testTokenWithDifferentActionsFail(self):
    method testTokenWithDifferentUsersFail (line 43) | def testTokenWithDifferentUsersFail(self):
    method testExpiredTokenDoesNotVerify (line 47) | def testExpiredTokenDoesNotVerify(self):

FILE: python/country_servers.py
  function get_region_for_country (line 300) | def get_region_for_country( client_country ):
  function get_all_servers (line 307) | def get_all_servers():
  class RegionalRoomServer (line 324) | class RegionalRoomServer(ndb.Model):

FILE: python/handlers.py
  class RootHandler (line 21) | class RootHandler(handlers.BaseHandler):
    method get (line 23) | def get(self):
  class ConfigHandler (line 27) | class ConfigHandler(handlers.BaseHandler):
    method get (line 29) | def get(self):
  class CspHandler (line 37) | class CspHandler(handlers.BaseAjaxHandler):
    method post (line 39) | def post(self):

FILE: python/main_test.py
  class MainTest (line 24) | class MainTest(unittest2.TestCase):
    method _VerifyInheritance (line 27) | def _VerifyInheritance(self, routes_list, base_class):
    method testRoutesInheritance (line 46) | def testRoutesInheritance(self):
    method testStrictHandlerMethodRouting (line 68) | def testStrictHandlerMethodRouting(self):
Condensed preview — 176 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,121K chars).
[
  {
    "path": ".gitignore",
    "chars": 97,
    "preview": "/node_modules\n/.idea\n.DS_Store\nbuild/main.js\n*.pyc\nbuild/\n*.swp\n.sass-cache\nnpm-debug.log\n\n*.asd\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 983,
    "preview": "# How to contribute\n\nWe'd love to accept your patches and contributions to this project. There are\njust a few small guid"
  },
  {
    "path": "LICENSE",
    "chars": 11357,
    "preview": "\n                                 Apache License\n                           Version 2.0, January 2004\n                  "
  },
  {
    "path": "README.md",
    "chars": 4906,
    "preview": "<table>\n  <tr>\n    <td>\n      This project is no longer actively maintained by the Google Creative Lab but remains here "
  },
  {
    "path": "app.yaml",
    "chars": 2880,
    "preview": "# Copyright 2017 Google Inc.\n#\n#   Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use thi"
  },
  {
    "path": "backend/.babelrc",
    "chars": 56,
    "preview": "{\n  \"presets\": [\"es2015\", \"stage-2\"],\n  \"plugins\": []\n}\n"
  },
  {
    "path": "backend/.gitignore",
    "chars": 898,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\n\n# tmp/testing\nscratch\ntmp\n*.bak\n\n# vim swap files\n*.swp\n*.swo\n\n# Mac filesystem entrie"
  },
  {
    "path": "backend/README.md",
    "chars": 28147,
    "preview": "# webvr-experiments: Musical Forest back-end\n\n## Contents\n\n* [Description](#description)\n* [Running the app locally](#ru"
  },
  {
    "path": "backend/package.json",
    "chars": 2032,
    "preview": "{\n  \"name\": \"webvr-musicalforest-backend\",\n  \"version\": \"1.0.0\",\n  \"description\": \"node.js websocket app server for fore"
  },
  {
    "path": "backend/src/config.js",
    "chars": 8960,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/datastore/datastore-constants.js",
    "chars": 817,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/datastore/index.js",
    "chars": 1821,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/index.js",
    "chars": 1089,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/logger/cloud-logger.js",
    "chars": 1515,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/logger/index.js",
    "chars": 1892,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/index.js",
    "chars": 962,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-actions.js",
    "chars": 2736,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-constants.js",
    "chars": 9607,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-handler.js",
    "chars": 4885,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-rate-limiter.js",
    "chars": 2591,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-sagas.js",
    "chars": 28369,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-schema.js",
    "chars": 15437,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/messages/message-validator.js",
    "chars": 3593,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/pubsub/index.js",
    "chars": 674,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/pubsub/pubsub-client.js",
    "chars": 1362,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/index.js",
    "chars": 1287,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-data-actions.js",
    "chars": 6224,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-data-constants.js",
    "chars": 3733,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-data-reducer.js",
    "chars": 21998,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-names.js",
    "chars": 70637,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-sagas.js",
    "chars": 38253,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-state-actions.js",
    "chars": 5569,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-state-constants.js",
    "chars": 3475,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-state-machine.js",
    "chars": 7040,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-state-reducer.js",
    "chars": 4463,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/rooms/room-state-utils.js",
    "chars": 1271,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/s11n.js",
    "chars": 836,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/app-server.js",
    "chars": 1347,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/balancer.js",
    "chars": 6439,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/index.js",
    "chars": 880,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-actions.js",
    "chars": 5186,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-constants.js",
    "chars": 3876,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-reducer.js",
    "chars": 10619,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-sagas.js",
    "chars": 18927,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-setup.js",
    "chars": 8933,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-startup.js",
    "chars": 4116,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-sync-handlers.js",
    "chars": 1846,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/server-sync.js",
    "chars": 2951,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/target-server-utils.js",
    "chars": 1013,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/server/websocket-server.js",
    "chars": 8317,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/index.js",
    "chars": 1528,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/sphere-constants.js",
    "chars": 1041,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/0.js",
    "chars": 2343,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/1.js",
    "chars": 3363,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/2.js",
    "chars": 5122,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/3.js",
    "chars": 6438,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/4.js",
    "chars": 3176,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/spheres/trees/5.js",
    "chars": 6772,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/store.js",
    "chars": 2474,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/index.js",
    "chars": 852,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/reducer-utils.js",
    "chars": 2587,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/saga-utils.js",
    "chars": 2081,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/string-utils.js",
    "chars": 834,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/url-utils.js",
    "chars": 1491,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "backend/src/utils/websocket-utils.js",
    "chars": 2859,
    "preview": "/*\n * Copyright 2017 Google Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not us"
  },
  {
    "path": "config.js",
    "chars": 822,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "config.template",
    "chars": 171,
    "preview": "var CONFIG = {}\nCONFIG.DEFAULT_REGION = \"{{default_region}}\";\n\nCONFIG.SERVERS = {\n{% for server in servers %}\n    \"{{ser"
  },
  {
    "path": "index.html",
    "chars": 4360,
    "preview": "<!DOCTYPE html>\n<html>\n<head>\n    <title>The Musical Forest</title>\n    <meta name=\"description\" content=\"Musical Forest"
  },
  {
    "path": "js/ascene.js",
    "chars": 4152,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/background-objects.js",
    "chars": 3776,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/ball.js",
    "chars": 3055,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/bg-tree-ring-material.js",
    "chars": 1736,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/clicker.js",
    "chars": 3470,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/controller-material.js",
    "chars": 2404,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/controllers.js",
    "chars": 8043,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/copresence-server-messages.js",
    "chars": 4531,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/copresence-server.js",
    "chars": 30556,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/daydream-manager.js",
    "chars": 3007,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/daydream-pointer.js",
    "chars": 1803,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/fake-light.js",
    "chars": 790,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/ga.js",
    "chars": 899,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/gaze.js",
    "chars": 6238,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/grab-move.js",
    "chars": 10855,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/haptics.js",
    "chars": 1103,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/headset-material.js",
    "chars": 1766,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/listener.js",
    "chars": 1522,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/palette.js",
    "chars": 3772,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/proximity-check.js",
    "chars": 3248,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/quaternion.js",
    "chars": 1019,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/smooth-motion.js",
    "chars": 3347,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/teleport.js",
    "chars": 6978,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/tone.js",
    "chars": 3692,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/tool-tips.js",
    "chars": 4292,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/touch-color.js",
    "chars": 9161,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/tree.js",
    "chars": 4479,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/components/wasd-boundaries.js",
    "chars": 1826,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/color-set.js",
    "chars": 1044,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/colors.js",
    "chars": 5189,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/instrument.js",
    "chars": 1712,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/instruments.js",
    "chars": 1213,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/shape-data.js",
    "chars": 1258,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/core/shapes.js",
    "chars": 2325,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/index.js",
    "chars": 2078,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-head-cube.js",
    "chars": 1029,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-head-sphere.js",
    "chars": 1034,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-head-tetra.js",
    "chars": 1260,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-head.js",
    "chars": 4033,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-shadow-cube.js",
    "chars": 1146,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-shadow-sphere.js",
    "chars": 1233,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-shadow-tetra.js",
    "chars": 1194,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-shadow.js",
    "chars": 1279,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note-stem.js",
    "chars": 1346,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/notes/note.js",
    "chars": 3496,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/orientation-arm-model.js",
    "chars": 7407,
    "preview": "/*\n * Copyright 2016 Google Inc. All Rights Reserved.\n * Licensed under the Apache License, Version 2.0 (the \"License\");"
  },
  {
    "path": "js/shaders/ball-shader.js",
    "chars": 2656,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/shaders/bg-tree-shader.js",
    "chars": 1802,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/shaders/circle-shader.js",
    "chars": 1096,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/shaders/shader-chunks.js",
    "chars": 2707,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/splash.js",
    "chars": 14297,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the 'License');\n// you may not use"
  },
  {
    "path": "js/util/browserCheck.js",
    "chars": 861,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/util/random-range-1d.js",
    "chars": 777,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/util/random-range-3d.js",
    "chars": 1081,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/util/tetrahedron.js",
    "chars": 1640,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/util/trace.js",
    "chars": 974,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "js/util.js",
    "chars": 2867,
    "preview": "// Copyright 2017 Google Inc.\n//\n//   Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use"
  },
  {
    "path": "package.json",
    "chars": 1826,
    "preview": "{\n  \"name\": \"webvr-musical-forest\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"authors\": [],\n  \"scripts\": {\n    \"star"
  },
  {
    "path": "python/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "python/base/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "python/base/api_fixer.py",
    "chars": 6708,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/api_fixer_test.py",
    "chars": 1803,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/constants.py",
    "chars": 2245,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/handlers.py",
    "chars": 16936,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/handlers_test.py",
    "chars": 6058,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/models.py",
    "chars": 1136,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/models_test.py",
    "chars": 1150,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/xsrf.py",
    "chars": 1842,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/base/xsrf_test.py",
    "chars": 1924,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/country_servers.py",
    "chars": 6442,
    "preview": "# Copyright 2017 Google Inc.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this "
  },
  {
    "path": "python/handlers.py",
    "chars": 1546,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/main.py",
    "chars": 5598,
    "preview": "# Copyright 2014 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "python/main_test.py",
    "chars": 3804,
    "preview": "# Copyright 2015 Google Inc. All rights reserved.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# "
  },
  {
    "path": "static/models/bg-tree-1.obj",
    "chars": 3202,
    "preview": "# WaveFront *.obj file (generated by CINEMA 4D)\n\ng T0\nusemtl default\nv 0.002211 -50319.394575 643.569099\nv -0.001393 503"
  },
  {
    "path": "static/models/bg-tree-2.obj",
    "chars": 3061,
    "preview": "# WaveFront *.obj file (generated by CINEMA 4D)\n\ng T1\nusemtl Mat\nv 47.567647 24592.814791 609.948777\nv 47.567647 24311.5"
  },
  {
    "path": "static/models/bg-tree-3.obj",
    "chars": 4922,
    "preview": "# WaveFront *.obj file (generated by CINEMA 4D)\n\ng T2\nusemtl default\nv 0.002266 -50319.395029 643.569333\nv -0.001339 503"
  },
  {
    "path": "static/models/bg-tree-ring.obj",
    "chars": 9676,
    "preview": "# WaveFront *.obj file (generated by CINEMA 4D)\n\ng Cylinder\nusemtl Mat\nv 50 0 0\nv 50 100 0\nv 49.240388 0 -8.682409\nv 49."
  },
  {
    "path": "static/models/controller.dae",
    "chars": 13894,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<COLLADA xmlns=\"http://www.collada.org/2008/03/COLLADASchema\" version=\"1.5.0\">\n\t<"
  },
  {
    "path": "static/models/door-frame.obj",
    "chars": 1498,
    "preview": "# Blender v2.76 (sub 0) OBJ File: ''\n# www.blender.org\no DoorFrame\nv 2.860165 0.000004 -0.260001\nv 2.704165 0.000004 -0."
  },
  {
    "path": "static/models/door-glow.obj",
    "chars": 575,
    "preview": "# Blender v2.78 (sub 0) OBJ File: ''\n# www.blender.org\n\no Glow.001_ID7.001\nv 3.023290 -0.039070 -1.075025\nv 3.033190 5.1"
  },
  {
    "path": "static/models/door.dae",
    "chars": 28793,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<COLLADA xmlns=\"http://www.collada.org/2008/03/COLLADASchema\" version=\"1.5.0\">\n\t<"
  },
  {
    "path": "static/models/headset.dae",
    "chars": 19152,
    "preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<COLLADA xmlns=\"http://www.collada.org/2008/03/COLLADASchema\" version=\"1.5.0\">\n\t<"
  },
  {
    "path": "static/models/stump-shadow.obj",
    "chars": 39772,
    "preview": "# WaveFront *.obj file (generated by CINEMA 4D)\r\n\r\nmtllib ./shadowUpdated.mtl\r\n\r\nv 0.14950437671081 0.05640361318484 0.2"
  },
  {
    "path": "static/models/stump.obj",
    "chars": 205620,
    "preview": "# Blender v2.76 (sub 0) OBJ File: 'stump.blend'\n# www.blender.org\nmtllib stump-test.mtl\no Mesh\nv -0.107668 0.351923 -0.1"
  },
  {
    "path": "static/models/target.obj",
    "chars": 3985,
    "preview": "# Blender v2.76 (sub 0) OBJ File: 'target.blend'\n# www.blender.org\no Cylinder_Cylinder.002\nv 0.000000 0.000000 -0.319792"
  },
  {
    "path": "style/splash.scss",
    "chars": 12135,
    "preview": "@font-face {\n  font-family: 'PlatformMedium';\n  src: url('/static/fonts/Platform-Medium-Web.eot');\n  src: url('/static/f"
  },
  {
    "path": "third_party/aframe-daydream-controller-component/LICENSE",
    "chars": 1076,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2015 Kevin Ngo\n\nPermission is hereby granted, free of charge, to any person obtaini"
  },
  {
    "path": "third_party/aframe-daydream-controller-component/METADATA",
    "chars": 281,
    "preview": "name: \"aframe-daydream-controller-component\"\ndescription:\n    \"Aframe component for daydream\"\n\nthird_party {\n  url {\n   "
  },
  {
    "path": "third_party/aframe-daydream-controller-component/daydream-controller.js",
    "chars": 16910,
    "preview": "import OrientationArmModel from '../../js/orientation-arm-model'\n\nif (typeof AFRAME === 'undefined') {\n    throw new Err"
  }
]

// ... and 19 more files (download for full content)

About this extraction

This page contains the full source code of the googlecreativelab/webvr-musicalforest GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 176 files (1.0 MB), approximately 375.7k tokens, and a symbol index with 376 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!