diff options
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 472 |
1 files changed, 472 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ed8f53 --- /dev/null +++ b/README.md @@ -0,0 +1,472 @@ +# Yesod + +Small self-hosted VR rooms. Anyone can start a room in a world, and a few +people can sit around and talk. It runs in the headset's web browser +(WebXR), so there's nothing to install on a Quest, and everything, including +every library and model, is served from your own server. Nothing is logged +about visitors, and a relay can hide everyone's IP address from the others. + +The name is the sphere of the Tree of Life that holds the astral plane: the +world of images, where people meet without their bodies. + +It comes with five worlds: + +- **Campfire:** a pine clearing under the real night sky. +- **Temple:** a Thelemic temple with the Gnostic Mass hall and the Book 4 + chamber. In VR, reach your hand to the Veil and a label above your + hand offers to open or close it with the trigger; on desktop, press V. It + opens and closes for everyone in the room. You can pick up the Wand and the + Sword from the chamber's altar and draw in the air with them, like the pens + in VRChat. +- **Cabin:** a log cabin by a lake: a fire in a stone hearth, a chess table + on a hexagram rug with an armchair at each side and a third by the fire, a + bookshelf, and a big window where the sky holds the real Moon (true phase, + drawn 2.5× its real size) over the water. In the window-side corner a lantern + hangs over a magick circle and triangle after Lon Milo DuQuette's "Modern + Circle" (Book 4's Tau, diamonds and nine lamps, with the names NVIT, HADIT, + RA HOOR KHVIT, CHAOS, BABALON, LAYLAH and PERDVRABO), scaled down from 9 ft + to about 1.7 m. A black and a white wand lie on the windowsill for drawing + in the air, and on desktop V resets the chess board. +- **Balcony:** a balcony 40 storeys up a glass tower, over a big city on an + overcast, foggy day: two chairs at a little table, a bench, and planters. + The city is plain boxes painted with window textures; the fog and two + drifting cloud layers (one hiding the street, one swallowing the tallest + towers) keep it moody and cheap to draw, and red aircraft lights blink on + the roofs. Wind plays in the background. +- **Test room:** a plain white room for trying things out, with a chess board + on its table (see [Chess](#chess)). + +## Starting and joining rooms + +The first page lists the open rooms: which world, who started it, how long +it's been going, and how many people are in it (a lock means it has a +password). Tap **Join** on one, or **Start a room**: + +- **World:** which place to go. New worlds are listed in `public/worlds.json`. +- **Open room:** listed on the first page for anyone to join. An optional + password keeps it to people you tell; the room is still listed, with a lock. +- **Just me:** the world runs on your device only. Nothing is listed, nothing + connects to the server after the page and world have downloaded, and the + mic isn't used. + +Then pick a name, an avatar and a color, allow the microphone, and tap +**Enter VR**. **Copy link** in a room copies a link straight to its join +screen (the password isn't in it). Each browser remembers your name, avatar, +color, world, choice of room type and the setting below. + +### Sharing a screen + +When joining or starting an open room from a computer, **Join as → My screen** +puts what's on that computer into the room instead of you: no avatar or mic, +just a big screen everyone sees, labeled with your name. The browser asks which +screen, window or tab to show, and the next page shows what's going out, lets +you share something else or stop, and picks the sound. Several people can each +share a screen at once. + +In the room, anyone can move a screen. In VR, point a hand at it and squeeze +grip; it follows your hand at that distance, upright and turned toward you. +Squeeze the other grip too and move your hands apart or together to resize it. +On desktop, look at it and press **G**, scroll to resize, and **G** again to +put it down. Everyone sees where it is, newcomers included. + +Sound depends on the browser doing the sharing: + +| Browser | Sound | +|---|---| +| Chrome, Edge, Chromium, Brave | A shared tab's sound (tick **Also share tab audio**). On Windows, sharing the whole screen can include all the computer's sound. | +| Firefox, Safari (any OS) | None from sharing itself, so pick a sound input on the next page instead. | + +Any browser can use a sound input. On Linux (PipeWire or PulseAudio), choose +**Monitor of …** for everything the computer plays; in Firefox you can pick it +right in the permission prompt. On Windows, enable **Stereo Mix** (Sound +settings → Recording) or install a virtual cable; on a Mac, a loopback device +like BlackHole. Don't also join the room as yourself from the same computer +while sharing its sound that way, or people hear themselves. + +The screen goes out over the same WebRTC connections as voice, so it never +touches the server. The relay carries it when **Hide my IP address** is on, +using about 3 Mbps per viewer at most (1080p, 30 fps), plus 128 kbps stereo +sound. The sharer's upload is that times the number of viewers. + +**Hide my IP address** (on by default) sends your voice and movement through +the relay on your server, so others in the room only ever see the server's address. +Turning it off lets your headset connect straight to the others when it can, +with slightly less delay, but then they can see your IP address. It's +per person: someone who turns it off reveals only their own address. The +setting only appears when the server has a relay configured. + +A room closes a minute after the last person leaves, so reloading the page +brings you back to the same room. A room nobody enters closes after two +minutes. + +| | In VR | On desktop | +|---|---|---| +| Walk | Left stick | WASD / arrows | +| Turn | Right stick (30° snaps) | Q / E, or drag to look | +| Jump | A | Space | +| Sit / stand | X | C | +| Mute | Y or B | M, or the Mic button | +| Pick up / let go (Temple) | Hold grip near the Wand or Sword / release grip | F | +| Draw | Trigger, while holding | Hold the mouse button | +| Erase | Touch a line with an empty hand and pull the trigger | Backspace erases your last line | +| Move a chess piece (Test room) | Hold grip near it, let go over a square | F on it, look at a square, F again | +| Set up the chess board again | Reach for a red button on the table, trigger twice | V | + +In VR the Wand or Sword goes into your hand by its hilt, and when you let go +it floats right where it was until someone picks it up again. Lines come from +the tip: gold from the Wand, pale blue from the Sword. Everyone in the room +sees who's holding what and every line, newcomers are caught up, and no one +can take a weapon out of someone else's hand. A room keeps its 300 newest +lines (100,000 points); the oldest go first. + +### Chess + +The board doesn't know the rules; you move the pieces yourself, like a real +board. The piece your hand would pick up glows, and while you hold one over the +board the square under it lights up, red if there's a piece there already. Let +go and the piece stands in the middle of that square, and the piece that was +there goes off to the side of the board (black's on white's right, white's on +black's). Let go anywhere else and it drops onto the table, or the floor. On +desktop the held piece hovers over wherever you look. Everyone sees the same +board, newcomers included. White is at the end of the table where you arrive. +It's all in `public/js/components/chess.js`; a world adds it with +`<a-entity chess position="…">` on its tabletop. The Test room and the Cabin +each have one; the Cabin builds its table and chairs around wherever the board +is put. + +Your left wrist shows whether your mic is on and who's here. Everyone else +sees your avatar follow your head and reach for your hands, with your finger +gestures (point, fist, thumbs-up) from the controller buttons. The mouth moves +when you talk, voices come from where people stand, and a dot under each name +glows while that person speaks. + +Sitting puts you on the nearest free log or stump (within about 1.6 m), +facing the fire; each log seats two. Your view settles to seated height +whether you're standing or already sitting at home, worked out from your +avatar's proportions so its hips land on the seat. Others see it sit with its +feet on the ground, leaning when you lean. Walking, jumping, or stepping more +than 0.6 m away in your room stands you up. A jump is a quick hop of about +half a metre, and others see your avatar's knees tuck up. + +## How it works + +``` +Quest browser ──HTTPS──▶ nginx /vr/ ──▶ yesod (Node, 127.0.0.1:8095) + │ • static files + │ • EasyRTC signaling over socket.io + │ • the list of open rooms, room passwords + │ • mints TURN credentials + └──WebRTC voice, avatar data, shared screens──▶ other headsets directly, + or via coturn (UDP 3478, relay 49160–49999) +``` + +- **Client:** [A-Frame](https://aframe.io) 1.7.1 with [Networked-A-Frame](https://github.com/networked-aframe/networked-aframe) 0.14.3 (easyrtc adapter, positional audio). Every connection's session description allows Opus stereo at up to 128 kbps (`stereoOpus` in `app.js`), so a shared screen's sound isn't squeezed into mono voice quality; voices use a little more bandwidth as a result. +- **Server:** `server.js`, using Express 4, socket.io and open-easyrtc. It keeps the open rooms in memory (nothing is written to disk), and each EasyRTC connection may only join the room it signed in to, with that room's password. After 10 wrong passwords in 10 minutes, that IP is locked out for the rest of the window. Room passwords ignore case and surrounding spaces. +- **Relay:** coturn with `use-auth-secret`, so each person gets their own TURN username and a credential that lasts 12 hours. It never relays into private or loopback addresses. With **Hide my IP address** on, the browser is set to `iceTransportPolicy: 'relay'`, so it only ever offers the relay's address. Each person then holds one relay allocation (and one UDP port) per other person in the room; `deploy/turnserver.conf` allows 450 in total and 10 per person, which covers the app's 60-person cap. + +### Limits + +The constants at the top of `server.js` keep a public server from swamping +a small VPS. The first four can be set in the environment +([Settings](#settings)): + +| Limit | Value | Why | +|---|---|---| +| People per room | 8 | Everyone streams voice and movement to everyone else, so a headset handles 7 others comfortably | +| Open rooms | 10 | | +| People in rooms, all together | 60 | | +| People from one IP address | 6 | Enough for a household | +| Open rooms started from one IP | 2 | | +| Rooms started per IP per 10 minutes | 6 | | + +Measured locally with 56 simulated people in 8 rooms, the server's memory +stayed at about 92 MB, the same as idle: the server only relays the brief +signaling that sets up each connection, so it's the relay (coturn) that grows with use. +Its memory per allocation is small (a few hundred allocations is a few tens of +MB); CPU and bandwidth go up with every person relayed. + +### Code map + +| File | What it does | +|---|---| +| `server.js` | HTTP, the room list and room API, passwords and limits, EasyRTC auth, ICE/TURN config | +| `public/worlds.json` | The worlds you can pick: name, line of description, thumbnail, fog, and an optional action and shared state | +| `public/js/world.js` | `CurrentWorld()`: finds the loaded world's component (`data-world`), which locomotion and avatars ask for seats, spawn, bounds and floor height | +| `public/index.html` | Lobby and HUD (monospace, centered, `---+---` rules; small inline CSS), the scene, the networked templates, and each world's `<template id="world-…">` | +| `public/js/app.js` | Room list, starting and joining rooms, loading the chosen world, mic/mute, Enter VR, the "who's here" list | +| `public/js/schemas.js` | What NAF syncs for each template; self-hosted hand models | +| `public/js/components/temple.js` | The temple world: the Gnostic Mass hall and the Book 4 chamber, the Veil, and the Wand and Sword | +| `public/js/components/campsite.js` | Builds the world (instanced models, fire, embers, fireflies, pond, jack-o'-lanterns) | +| `public/js/components/ambience.js` | Night sounds: the fire, wind in the trees, owls, twigs and footsteps out in the forest | +| `public/js/components/cabin.js` | The cabin world, built from boxes, cylinders and painted canvas textures, with Nature Kit pines outside | +| `public/js/components/balcony.js` | The balcony world: the city merged into two meshes (concrete and glass facades), the tower's balconies instanced, cloud layers and aircraft lights | +| `public/js/components/sky.js` | The live night sky: stars, Milky Way, Moon and planets, and the moonlight. `pinMoon` holds the Moon at a fixed bearing and altitude (the cabin's window) | +| `public/js/astro.js` | Where the Sun, Moon and planets are (ported from android-toolkit's `:astro`) | +| `public/js/components/vrm-avatar.js` | VRM body that follows the head, two-bone arm IK, lip-sync, blinking | +| `public/js/components/locomotion.js` | Stick/keyboard walking, snap turn, stay-in-the-clearing bounds | +| `public/js/components/hand-tilt.js` | Tips each tracked hand 30° down about the fist (the controller grip pose points too far up), so your hand models, whatever you hold, and the hands others see all angle more naturally. Change it with `hand-tilt="degrees: …"` on `#left-hand` / `#right-hand` | +| `public/js/components/wield.js` | Reaching for a world's `touchables`, picking up its `tools`, holding them by the grip, letting them float, drawing and erasing lines, and keeping all of it in sync (data channels `wield` and `ink`) | +| `public/js/share.js` | "My screen": picking what to share, the sound (from the share or a sound input), sending it to everyone as a stream named `screen`, and its page | +| `public/js/components/screens.js` | Shared screens in the world: showing the sharer's video with its sound coming from the screen, moving and resizing them, and keeping that in sync (data channel `screen`). It reads the sharer's tracks straight off the peer connection, since a screen-only connection carries nothing else | +| `public/js/components/nametag.js`, `voice-level.js`, `player-info.js` | Names, speaking dot, synced player details | + +### The sky + +The sky is the real sky over the site's location (`SKY_LATITUDE` and +`SKY_LONGITUDE`) at this moment, as it would look from a dark site: +the 9,096 stars of the Yale Bright Star Catalog, the Milky Way, and the Moon +and planets (Mercury to Uranus) where they really are. It always stays night. +The Sun isn't drawn, but it lights the Moon, so the phase is right. A bright +Moon washes out the faint stars and the Milky Way and lights the clearing a +little. Away from the firelight the forest is otherwise nearly black. + +True South is toward the pond, the most open view, since from the northern +hemisphere the Moon, the planets and the Milky Way's core all pass through the +southern sky. The trees hide the lowest 20° or so, which at mid-northern +latitudes includes the core. + +To look at another time, add `?sky=` to the address, and `&skyspeed=` to run +the clock faster (sky seconds per real second): + +``` +http://localhost:8095/?sky=2027-07-08T23:30:00-07:00&skyspeed=600 +``` + +The positions are checked against JPL Horizons (they agree to about 0.02°): + +```bash +deno test tools/sky/astro_test.js +``` + +The two data files are built by `tools/sky/` from their sources: +`build-stars.py` packs `bsc5.dat.gz` into `public/assets/sky/stars.bin`, and +`build-milkyway.py` turns NASA's `milkyway_2020_4k.exr` into +`public/assets/sky/milkyway-4k.jpg`. Each script's header has the download URL. + +### Sounds + +The fire crackles at the fire ring (it fades out by the edge of the +clearing), wind moves through the trees all around, and every 5 to 17 seconds +something happens out among the trees, 14 to 30 m away: a great horned or +barred owl calls (often again, from the same tree), a twig snaps, or footsteps +walk past. They play through the same listener as the voices, so they come +from where they happen. Loudness and frequency are the constants at the top of +`public/js/components/ambience.js`. + +The clips in `public/assets/sounds/` (1.2 MB of Ogg Opus) are cut from the +original recordings by `tools/sounds/build-sounds.py`, which lists where to +download each one. + +### Adding avatars + +Any **VRM 0.x** model with a standard humanoid rig works. Drop `name.vrm` and a +`name-portrait.webp` (192×192, transparent background) into +`public/assets/avatars/`, add `['name', 'Label']` to `AVATARS` in +`public/js/app.js`, then deploy. + +The portraits are rendered from the models themselves (head and torso, arms +relaxed, three-quarter view) by `tools/portraits/`: + +```bash +deno run --allow-net=127.0.0.1:8097 --allow-read --allow-write=tools/portraits tools/portraits/server.ts +``` + +Open `http://localhost:8097/?slugs=name` in a browser and wait for the tab +title to say "done". Then trim and size the PNG from `tools/portraits/out/`: + +```bash +magick tools/portraits/out/name.png -trim +repage -resize 168x168 -gravity south -background none -extent 192x192 -quality 88 public/assets/avatars/name-portrait.webp +``` Avatars are +scaled to a common height automatically. Keep them low-poly (under about 10k +triangles) for the Quest. The [open-source-avatars](https://github.com/ToxSam/open-source-avatars) +registry lists many more CC0 VRMs, including the rest of the 100Avatars series. + +### Adding a world + +1. Add an entry to `public/worlds.json` (`id`, `name`, `blurb`, `thumbnail`, + and optionally `fog`). The server only accepts rooms for worlds listed there. +2. Add a `<template id="world-<id>">` to `public/index.html` with the world's + entities (lights, sky, models). It's cloned into the scene when someone + picks that world, so its assets only download then. Mark the entity that + holds the world's main component with `data-world="<component>"`, and load + the component's script in the page's `<head>`. +3. Add a 640×360 `public/assets/worlds/<id>.webp` for the picker and room list. + +The marked component tells locomotion and the avatars about the place (see +`public/js/world.js`): + +| | | +|---|---| +| `seats` | `[{ x, y, z, yaw }]`, where X / C sits you (required, can be empty) | +| `spawn` | `{ x, z, yaw }` where people arrive. Without it, people arrive on a circle around the origin, facing in | +| `confine(headPos)` | moves a head position back inside the walkable area. Without it, people stay within 24 m of the origin and 0.9 m from it (the campfire) | +| `floorAt(x, z)` | floor height there; people ease up and down steps. Without it, the floor is flat at 0 | +| `screen` | `{ x, y, z, yaw, width }`: where a shared screen first appears (the middle of the picture; at yaw 0 it faces +z). Without it, 4 m ahead of `spawn` at 1.8 m | +| `touchables` | `[{ name, label(), distance(worldPos), use() }]`: things to reach for in VR (`wield.js`). Within 20 cm of an empty hand, a label above the hand reads "Trigger: " + `label()`, and the trigger calls `use()` | +| `tools` | `[{ name, label, object, grip, tip, butt, ink }]`: things to pick up and draw with (`wield.js`). Each `object` lies along its own +x; `grip`, `tip` and `butt` are local `Vector3`s and `ink` is a `#rrggbb` color | + +Two optional fields in `worlds.json` cover interaction: + +- `action: { label, method }`: V on desktop calls that method on the + component, and the controls hint shows the label. In VR the world offers the + same thing as a `touchable` that you reach for (below). +- `shared: [property, …]`: those component properties stay the same for + everyone in the room. Changes go to everyone, and people already there tell + each newcomer how things stand. Values from others are only accepted for + the listed properties and with the type they already have. + +The temple uses all of these (`action` and its one touchable toggle the +Veil, `shared` is `["veil"]`, and its `tools` are the Wand and the Sword). + +## Running it locally + +```bash +npm install +``` + +```bash +node server.js +``` + +Deno also runs it (`deno install`, then +`deno run --allow-net --allow-read --allow-env --allow-sys server.js`). + +Then open http://localhost:8095/ in two tabs, start an open room in one and +join it from the other. Without a relay configured, **Hide my IP address** +doesn't appear and people connect directly. Headsets need HTTPS for WebXR +and the microphone, so to try it on a Quest, put it behind a server with a +certificate (below) or a tunnel. + +## Settings + +The server reads these from the environment (`deploy/env.example` lists +them all). Every one is optional. + +| Variable | Default | What it does | +|---|---|---| +| `HOST`, `PORT` | `127.0.0.1`, `8095` | Where the app listens | +| `TURN_SECRET`, `TURN_URLS` | none | The coturn relay; needed for **Hide my IP address** | +| `STUN_URLS` | none | STUN servers, comma-separated | +| `SITE_NAME` | `Yesod` | What the lobby and the browser tab call the site | +| `SKY_LATITUDE`, `SKY_LONGITUDE` | Greenwich | Where the worlds' sky is seen from, in degrees (north and east positive) | +| `MAX_PEOPLE`, `MAX_ROOMS`, `MAX_CONNECTIONS`, `MAX_CONNECTIONS_PER_IP` | 8, 10, 60, 6 | The limits above | +| `EASYRTC_LOG_LEVEL` | `error` | EasyRTC's logging | + +## Installing on a server + +These steps are for Debian or Ubuntu with nginx already serving your domain +over HTTPS. Replace `example.org` with your domain throughout. Run them as root. + +1. Install Node.js and coturn, and add a user for the app: + + ```bash + apt install nodejs npm coturn + ``` + + ```bash + useradd --system --no-create-home --shell /usr/sbin/nologin yesod + ``` + +2. Copy `deploy/env.example` to `/etc/yesod/env` on the server. Fill in your + domain and a new secret (`openssl rand -hex 32`), and set your location for + the sky. Then let only root and the app read it: + + ```bash + chown root:yesod /etc/yesod/env && chmod 640 /etc/yesod/env + ``` + +3. Set up coturn: copy `deploy/turnserver.conf` to `/etc/turnserver.conf` + (keep Debian's original if you like), set `realm` to your domain and + `static-auth-secret` to the same secret as `TURN_SECRET`. On Debian, also set + `TURNSERVER_ENABLED=1` in `/etc/default/coturn` if it's there. Then + `systemctl restart coturn`. + +4. Open the relay's ports. With ufw: + + ```bash + ufw allow 3478 comment yesod && ufw allow 49160:49999/udp comment yesod + ``` + +5. Copy `deploy/yesod.service` to `/etc/systemd/system/` on the server, then: + + ```bash + systemctl daemon-reload && systemctl enable yesod + ``` + +6. Add nginx's part: copy `deploy/nginx-yesod.conf` to + `/etc/nginx/snippets/yesod.conf`, add `include snippets/yesod.conf;` to + your site's `server { listen 443 … }` block, then + `nginx -t && systemctl reload nginx`. It serves the app at `/vr/`; change + both `location` lines for another path. + +7. From your computer, copy `deploy/deploy.env.example` to + `deploy/deploy.env`, set `DEPLOY_TARGET`, and run `deploy/deploy.sh`. + +The service runs sandboxed with a 300 MB memory cap; the app itself uses +about 30 MB. On a small VPS the relay's bandwidth is the real limit (see +[Limits](#limits)). + +### Updating + +```bash +deploy/deploy.sh +``` + +This uploads the app, runs `npm ci` on the server, swaps in the new version +(the previous one is kept as `/opt/yesod.old`) and restarts the service. +The paths and the service name can be changed in `deploy/deploy.env`. + +```bash +journalctl -u yesod -f +``` + +### What it remembers about visitors + +Nothing is logged about who comes by or when: + +- The nginx snippet keeps no access log for the app, and logs only critical + errors there. +- coturn's log goes to `/dev/null`. To debug the relay, replace `no-stdout-log`, + `log-file` and `simple-log` with `syslog` and `verbose` for a while. +- The app logs only startup and its own errors. Rooms, wrong passwords and + per-IP counts live in memory only, are never written down, and are gone on + restart. The room list shows only the world, the starter's name, how long + it's been open and how many are in it. EasyRTC logs errors only. + +Voice and movement are encrypted end to end (WebRTC). With **Hide my IP +address** on (the default), they go through the relay, which carries them +without being able to hear or read them, and others in the room only see the +server's address. With it off, people connect directly when they can, and can +see each other's IP addresses (in open rooms, that includes strangers). + +### Changing the TURN secret + +The relay's credentials are minted from `TURN_SECRET`, which must match +`static-auth-secret` in `/etc/turnserver.conf`. To replace it (as root): + +```bash +new=$(openssl rand -hex 32); sed -i "s|^TURN_SECRET=.*|TURN_SECRET=$new|" /etc/yesod/env; sed -i "s|^static-auth-secret=.*|static-auth-secret=$new|" /etc/turnserver.conf; unset new; systemctl restart coturn yesod +``` + +### Removing it + +Stop and disable `yesod` and `coturn`, delete the `include` line from the +nginx site and reload nginx, remove the ufw rules, then delete `/opt/yesod`, +`/etc/yesod` and `/etc/systemd/system/yesod.service`. + +## Credits + +All third-party assets are free to use, and every one is self-hosted. + +- **Stele of Revealing photo** (temple): a replica photographed by A.M.A., public domain, via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Stelae_front.jpg) (`public/assets/temple/CREDITS.txt`). +- **Avatars:** [100Avatars](https://www.polygonalmind.com/100-avatars) by Polygonal Mind, CC0. +- **Chess pieces:** [Stylized Chess Pieces](https://opengameart.org/content/stylized-chess-pieces) by Lyricsz, CC0 (`public/assets/chess/CREDITS.txt`). +- **World models:** [Nature Kit](https://kenney.nl/assets/nature-kit) by Kenney, CC0 (`public/assets/env/LICENSE-kenney-nature-kit.txt`). +- **Milky Way:** [Deep Star Maps 2020](https://svs.gsfc.nasa.gov/4851), NASA/Goddard Space Flight Center Scientific Visualization Studio; Gaia DR2: ESA/Gaia/DPAC. +- **Stars:** [Yale Bright Star Catalog](http://tdc-www.harvard.edu/catalogs/bsc5.html), 5th revised edition (Hoffleit and Warren, 1991). +- **Planet positions:** Paul Schlyter, [How to compute planetary positions](https://stjarnhimlen.se/comp/ppcomp.html). +- **Sounds:** fire, wind, twigs, footsteps and the chess pieces' knocks (a gavel, dice on a table) from [BigSoundBank](https://bigsoundbank.com) by Joseph Sardin, CC0. Barred owl: National Park Service (Yellowstone), public domain, via [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Barred_Owl,_Yellowstone_National_Park.oga). Great horned owl: [XC450919](https://commons.wikimedia.org/wiki/File:Bubo_virginianus_-_Great_Horned_Owl_XC450919.mp3) by Michael & Katie LaTour (xeno-canto), [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/); the trimmed, filtered clips `owl-great-horned-*.ogg` are shared under the same license. +- **Hand models:** [aframevr/assets](https://github.com/aframevr/assets), MIT. +- **Font:** Roboto MSDF from A-Frame, Apache 2.0. +- **Code:** A-Frame (MIT), Networked-A-Frame (MIT), open-easyrtc (BSD-2-Clause), socket.io (MIT), Express (MIT). + |