diff --git a/views/archive/g/tether/.firebaserc b/views/archive/g/tether/.firebaserc
new file mode 100644
index 00000000..11aecb23
--- /dev/null
+++ b/views/archive/g/tether/.firebaserc
@@ -0,0 +1,5 @@
+{
+ "projects": {
+ "default": "tether-game"
+ }
+}
diff --git a/views/archive/g/tether/.gitignore b/views/archive/g/tether/.gitignore
new file mode 100644
index 00000000..a8f642ff
--- /dev/null
+++ b/views/archive/g/tether/.gitignore
@@ -0,0 +1,70 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+firebase-debug.log*
+firebase-debug.*.log*
+
+# Firebase cache
+.firebase/
+
+# Firebase config
+
+# Uncomment this if you'd like others to create their own Firebase project.
+# For a team working on the same Firebase project(s), it is recommended to leave
+# it commented so all members can deploy to the same project(s) in .firebaserc.
+# .firebaserc
+
+# 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
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+
+# 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
+
+# dotenv environment variables file
+.env
+
+# template html files
+source/main.template.html
+source/offline.template.html
\ No newline at end of file
diff --git a/views/archive/g/tether/404.html b/views/archive/g/tether/404.html
new file mode 100644
index 00000000..829eda8f
--- /dev/null
+++ b/views/archive/g/tether/404.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+ Page Not Found
+
+
+
+
+
+
404
+
Page Not Found
+
The specified file was not found on this website. Please check the URL for mistakes and try again.
+
Why am I seeing this?
+
This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html
file in your project's configured public
directory.
+
+
+
diff --git a/views/archive/g/tether/CODE_OF_CONDUCT.md b/views/archive/g/tether/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..632d4aa0
--- /dev/null
+++ b/views/archive/g/tether/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+rayhanadev@protonmail.com.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/views/archive/g/tether/CONTRIBUTING.md b/views/archive/g/tether/CONTRIBUTING.md
new file mode 100644
index 00000000..f65752a6
--- /dev/null
+++ b/views/archive/g/tether/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+# Contributing
+
+When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change.
+
+Please note the following:
+
+- We have a code of conduct, please follow it in all your interactions with the project.
+- We follow the 'Conventional Commits' commit convention. If your pull request does not adhere to the convention, it will not be merged.
+
+## Pull Request Process
+
+1. Complete a quick code review for your code, you might catch any errors before you submit.
+2. Make sure your pull request uses the most recent version of the code.
+3. Update the README.md with changes if necessary. This includes any major changes to the features, usage of the package, etc.
+4. Increase the version numbers in any examples files and the README.md to the new version that this Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
+5. You may merge the Pull Request in once you have the approval of repository maintainer or owner, or if you do not have permission to do that, you may request a reviewer to merge it for you.
diff --git a/views/archive/g/tether/LICENSE b/views/archive/g/tether/LICENSE
new file mode 100644
index 00000000..e49342ba
--- /dev/null
+++ b/views/archive/g/tether/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Ray Arayilakath
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/views/archive/g/tether/README.md b/views/archive/g/tether/README.md
new file mode 100644
index 00000000..47b4f4a0
--- /dev/null
+++ b/views/archive/g/tether/README.md
@@ -0,0 +1,30 @@
+# **tether!** Swing Around a Ball of Destruction!
+
+![demo](https://tether.rayhanadev.repl.co/public/tether_cover.png)
+
+### PLAY IT [FULLSCREEN](https://tether.rayhanadev.repl.co) NOW!
+
+---
+
+**tether!** is game where you **wreck as many enemies as possible** using your tether, however if an enemy touches your ball then **you get obliterated**! This game has full mobile and offline support, designed as a progressive web application! This is my group's end-of-the-year project for our CS class.
+
+## How to Play
+**Click (or tap)** on the ball and **drag** it around! Use the motion of the ball and tether to **destroy each of the enemies**, the *Drifter*, the *Eye*, and the scariest of all... the *Twitchy*.
+
+## Features
+- **Fast** Load Times
+- Polished, Unique Graphics (no third party libs)
+- **Vibin'** Background Music
+- Full **Mobile Support**
+- **Offline Support**
+- **Custom** Made Everything
+- Progressive Web Application
+
+## Tips
+The first few levels are tutorials, after a few rounds with each character **you'll enter an endless wave** so be prepared.
+
+Keep an eye on **enemies spawning in**, they will charge at you **blazing fast** after spawning in!
+
+**Don't let your tether go wack**! If you move it around too much, it becomes **uncontrollable** and you have an awful time dealing with *Eyes*.
+
+Instead of charging a *Twitchy* head on, wait for it to **run out of fuel** and then destroy it.
diff --git a/views/archive/g/tether/favicon.ico b/views/archive/g/tether/favicon.ico
new file mode 100644
index 00000000..52e6cdab
Binary files /dev/null and b/views/archive/g/tether/favicon.ico differ
diff --git a/views/archive/g/tether/firebase.json b/views/archive/g/tether/firebase.json
new file mode 100644
index 00000000..c9bd4946
--- /dev/null
+++ b/views/archive/g/tether/firebase.json
@@ -0,0 +1,10 @@
+{
+ "hosting": {
+ "public": ".",
+ "ignore": [
+ "firebase.json",
+ "**/.*",
+ "**/node_modules/**"
+ ]
+ }
+}
diff --git a/views/archive/g/tether/fonts/Quantico400.woff2 b/views/archive/g/tether/fonts/Quantico400.woff2
new file mode 100644
index 00000000..d4498b76
Binary files /dev/null and b/views/archive/g/tether/fonts/Quantico400.woff2 differ
diff --git a/views/archive/g/tether/fonts/Quantico700.woff2 b/views/archive/g/tether/fonts/Quantico700.woff2
new file mode 100644
index 00000000..517e134d
Binary files /dev/null and b/views/archive/g/tether/fonts/Quantico700.woff2 differ
diff --git a/views/archive/g/tether/fonts/Tulpen-One400.woff2 b/views/archive/g/tether/fonts/Tulpen-One400.woff2
new file mode 100644
index 00000000..16e8b16e
Binary files /dev/null and b/views/archive/g/tether/fonts/Tulpen-One400.woff2 differ
diff --git a/views/archive/g/tether/icons/android-icon-144x144.png b/views/archive/g/tether/icons/android-icon-144x144.png
new file mode 100644
index 00000000..4a480bf3
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-144x144.png differ
diff --git a/views/archive/g/tether/icons/android-icon-192x192.png b/views/archive/g/tether/icons/android-icon-192x192.png
new file mode 100644
index 00000000..ea1cb0ea
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-192x192.png differ
diff --git a/views/archive/g/tether/icons/android-icon-36x36.png b/views/archive/g/tether/icons/android-icon-36x36.png
new file mode 100644
index 00000000..9ba08ddf
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-36x36.png differ
diff --git a/views/archive/g/tether/icons/android-icon-48x48.png b/views/archive/g/tether/icons/android-icon-48x48.png
new file mode 100644
index 00000000..b36441c2
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-48x48.png differ
diff --git a/views/archive/g/tether/icons/android-icon-72x72.png b/views/archive/g/tether/icons/android-icon-72x72.png
new file mode 100644
index 00000000..cd1e9e06
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-72x72.png differ
diff --git a/views/archive/g/tether/icons/android-icon-96x96.png b/views/archive/g/tether/icons/android-icon-96x96.png
new file mode 100644
index 00000000..a4d8c1a4
Binary files /dev/null and b/views/archive/g/tether/icons/android-icon-96x96.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-114x114.png b/views/archive/g/tether/icons/apple-icon-114x114.png
new file mode 100644
index 00000000..10cbc85b
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-114x114.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-120x120.png b/views/archive/g/tether/icons/apple-icon-120x120.png
new file mode 100644
index 00000000..724fc4e1
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-120x120.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-144x144.png b/views/archive/g/tether/icons/apple-icon-144x144.png
new file mode 100644
index 00000000..4a480bf3
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-144x144.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-152x152.png b/views/archive/g/tether/icons/apple-icon-152x152.png
new file mode 100644
index 00000000..396eb957
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-152x152.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-180x180.png b/views/archive/g/tether/icons/apple-icon-180x180.png
new file mode 100644
index 00000000..d1dead95
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-180x180.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-57x57.png b/views/archive/g/tether/icons/apple-icon-57x57.png
new file mode 100644
index 00000000..bf9f7205
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-57x57.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-60x60.png b/views/archive/g/tether/icons/apple-icon-60x60.png
new file mode 100644
index 00000000..0c89467c
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-60x60.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-72x72.png b/views/archive/g/tether/icons/apple-icon-72x72.png
new file mode 100644
index 00000000..cd1e9e06
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-72x72.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-76x76.png b/views/archive/g/tether/icons/apple-icon-76x76.png
new file mode 100644
index 00000000..9711ea98
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-76x76.png differ
diff --git a/views/archive/g/tether/icons/apple-icon-precomposed.png b/views/archive/g/tether/icons/apple-icon-precomposed.png
new file mode 100644
index 00000000..8c574eef
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon-precomposed.png differ
diff --git a/views/archive/g/tether/icons/apple-icon.png b/views/archive/g/tether/icons/apple-icon.png
new file mode 100644
index 00000000..8c574eef
Binary files /dev/null and b/views/archive/g/tether/icons/apple-icon.png differ
diff --git a/views/archive/g/tether/icons/browserconfig.xml b/views/archive/g/tether/icons/browserconfig.xml
new file mode 100644
index 00000000..c5541482
--- /dev/null
+++ b/views/archive/g/tether/icons/browserconfig.xml
@@ -0,0 +1,2 @@
+
+#ffffff
\ No newline at end of file
diff --git a/views/archive/g/tether/icons/favicon-16x16.png b/views/archive/g/tether/icons/favicon-16x16.png
new file mode 100644
index 00000000..b2ace7ee
Binary files /dev/null and b/views/archive/g/tether/icons/favicon-16x16.png differ
diff --git a/views/archive/g/tether/icons/favicon-32x32.png b/views/archive/g/tether/icons/favicon-32x32.png
new file mode 100644
index 00000000..6c3622a9
Binary files /dev/null and b/views/archive/g/tether/icons/favicon-32x32.png differ
diff --git a/views/archive/g/tether/icons/favicon-96x96.png b/views/archive/g/tether/icons/favicon-96x96.png
new file mode 100644
index 00000000..a4d8c1a4
Binary files /dev/null and b/views/archive/g/tether/icons/favicon-96x96.png differ
diff --git a/views/archive/g/tether/icons/ms-icon-144x144.png b/views/archive/g/tether/icons/ms-icon-144x144.png
new file mode 100644
index 00000000..4a480bf3
Binary files /dev/null and b/views/archive/g/tether/icons/ms-icon-144x144.png differ
diff --git a/views/archive/g/tether/icons/ms-icon-150x150.png b/views/archive/g/tether/icons/ms-icon-150x150.png
new file mode 100644
index 00000000..7d39a0ae
Binary files /dev/null and b/views/archive/g/tether/icons/ms-icon-150x150.png differ
diff --git a/views/archive/g/tether/icons/ms-icon-310x310.png b/views/archive/g/tether/icons/ms-icon-310x310.png
new file mode 100644
index 00000000..12373978
Binary files /dev/null and b/views/archive/g/tether/icons/ms-icon-310x310.png differ
diff --git a/views/archive/g/tether/icons/ms-icon-70x70.png b/views/archive/g/tether/icons/ms-icon-70x70.png
new file mode 100644
index 00000000..d96d70bc
Binary files /dev/null and b/views/archive/g/tether/icons/ms-icon-70x70.png differ
diff --git a/views/archive/g/tether/index.html b/views/archive/g/tether/index.html
new file mode 100644
index 00000000..e8551a77
--- /dev/null
+++ b/views/archive/g/tether/index.html
@@ -0,0 +1 @@
+tether! Hey there, this game needs Javascript. Turn it on to experience the excitement!
\ No newline at end of file
diff --git a/views/archive/g/tether/libs/font-awesome.min.css b/views/archive/g/tether/libs/font-awesome.min.css
new file mode 100644
index 00000000..f11f6492
--- /dev/null
+++ b/views/archive/g/tether/libs/font-awesome.min.css
@@ -0,0 +1,4 @@
+/*!
+ * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
+ */@font-face{font-family:'FontAwesome';src:url('./fontawesome-webfont.woff') format('woff');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}
\ No newline at end of file
diff --git a/views/archive/g/tether/libs/fontawesome-webfont.woff b/views/archive/g/tether/libs/fontawesome-webfont.woff
new file mode 100644
index 00000000..400014a4
Binary files /dev/null and b/views/archive/g/tether/libs/fontawesome-webfont.woff differ
diff --git a/views/archive/g/tether/offline/index.html b/views/archive/g/tether/offline/index.html
new file mode 100644
index 00000000..f65c39b6
--- /dev/null
+++ b/views/archive/g/tether/offline/index.html
@@ -0,0 +1 @@
+tether! Hey there, this game needs Javascript. Turn it on to experience the excitement!
\ No newline at end of file
diff --git a/views/archive/g/tether/public/tether_cover.png b/views/archive/g/tether/public/tether_cover.png
new file mode 100644
index 00000000..cc6983a3
Binary files /dev/null and b/views/archive/g/tether/public/tether_cover.png differ
diff --git a/views/archive/g/tether/public/tether_logo-v2.png b/views/archive/g/tether/public/tether_logo-v2.png
new file mode 100644
index 00000000..472c9b41
Binary files /dev/null and b/views/archive/g/tether/public/tether_logo-v2.png differ
diff --git a/views/archive/g/tether/public/tether_logo.png b/views/archive/g/tether/public/tether_logo.png
new file mode 100644
index 00000000..8e668f3b
Binary files /dev/null and b/views/archive/g/tether/public/tether_logo.png differ
diff --git a/views/archive/g/tether/public/tether_opengraphimage.png b/views/archive/g/tether/public/tether_opengraphimage.png
new file mode 100644
index 00000000..6266926e
Binary files /dev/null and b/views/archive/g/tether/public/tether_opengraphimage.png differ
diff --git a/views/archive/g/tether/public/tether_twittercardimage.png b/views/archive/g/tether/public/tether_twittercardimage.png
new file mode 100644
index 00000000..59cf5754
Binary files /dev/null and b/views/archive/g/tether/public/tether_twittercardimage.png differ
diff --git a/views/archive/g/tether/robots.txt b/views/archive/g/tether/robots.txt
new file mode 100644
index 00000000..64523c29
--- /dev/null
+++ b/views/archive/g/tether/robots.txt
@@ -0,0 +1,29 @@
+# Group 1
+User-agent: Googlebot
+Disallow: /.github
+Disallow: /fonts
+Disallow: /icons
+Disallow: /libs
+Disallow: /public
+Disallow: /source
+Disallow: /splashscreens
+Disallow: /bgm.mp3
+Disallow: /CODE_OF_CONDUCT.md
+Disallow: /LICENSE
+Disallow: /README.md
+
+# Group 2
+User-agent: *
+Disallow: /.github
+Disallow: /fonts
+Disallow: /icons
+Disallow: /libs
+Disallow: /public
+Disallow: /source
+Disallow: /splashscreens
+Disallow: /bgm.mp3
+Disallow: /CODE_OF_CONDUCT.md
+Disallow: /LICENSE
+Disallow: /README.md
+
+Sitemap: http://tether.rayhanadev.repl.co/sitemap.xml
\ No newline at end of file
diff --git a/views/archive/g/tether/service-worker.js b/views/archive/g/tether/service-worker.js
new file mode 100644
index 00000000..2304b92d
--- /dev/null
+++ b/views/archive/g/tether/service-worker.js
@@ -0,0 +1,35 @@
+const cacheName = 'tether_cache-v2';
+const precacheResources = [
+ '/',
+ '/offline/',
+ '/404.html',
+ '/fonts/Quantico400.woff2',
+ '/fonts/Quantico700.woff2',
+ '/fonts/Tulpen-One400.woff2',
+ '/icons/favicon-16x16.png',
+ '/tether_theme.mp3',
+ '/libs/font-awesome.min.css',
+ '/libs/fontawesome-webfont.woff'
+];
+
+// When the service worker is installing, open the cache and add the precache resources to it
+self.addEventListener('install', (event) => {
+ console.log('Service worker install event!');
+ event.waitUntil(caches.open(cacheName).then((cache) => cache.addAll(precacheResources)));
+});
+
+self.addEventListener('activate', (event) => {
+ console.log('Service worker activate event!');
+});
+
+// When there's an incoming fetch request, try and respond with a precached resource, otherwise fall back to the network
+self.addEventListener('fetch', (event) => {
+ event.respondWith(
+ caches.match(event.request).then((cachedResponse) => {
+ if (cachedResponse) {
+ return cachedResponse;
+ }
+ return fetch(event.request);
+ }),
+ );
+});
\ No newline at end of file
diff --git a/views/archive/g/tether/sitemap.xml b/views/archive/g/tether/sitemap.xml
new file mode 100644
index 00000000..d72c19d0
--- /dev/null
+++ b/views/archive/g/tether/sitemap.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ https://tether.rayhanadev.repl.co/
+ 2021-05-22T16:21:43+00:00
+
+
+ https://tether.rayhanadev.repl.co/robots.txt
+ 2021-05-22T16:21:43+00:00
+
+
+ https://tether.rayhanadev.repl.co/manifest.json
+ 2021-05-22T16:21:43+00:00
+
+
+
+
\ No newline at end of file
diff --git a/views/archive/g/tether/source/game.js b/views/archive/g/tether/source/game.js
new file mode 100644
index 00000000..f3ba5123
--- /dev/null
+++ b/views/archive/g/tether/source/game.js
@@ -0,0 +1,2488 @@
+document.body.classList.add('game');
+
+var storage = (function () {
+ var uid = new Date;
+ var storage;
+ var result;
+ try {
+ (storage = window.localStorage).setItem(uid, uid);
+ result = storage.getItem(uid) == uid;
+ storage.removeItem(uid);
+ return result && storage;
+ } catch (exception) { }
+ storage = function () { console.log('localStorage Disabled.') };
+ storage.getItem = function () { console.log('localStorage Disabled.') };
+ storage.setItem = function () { console.log('localStorage Disabled.') };
+ return storage;
+}());
+
+this.top.location !== this.location && (this.top.location = this.location);
+
+var DEBUG = window.location.hash === '#DEBUG',
+ INFO = DEBUG || window.location.hash === '#INFO',
+ game,
+ music,
+ canvas,
+ ctx,
+ devicePixelRatio = window.devicePixelRatio || 1,
+ width = window.innerWidth,
+ height = window.innerHeight,
+ muteButtonPosition,
+ muteButtonProximityThreshold = 30,
+ playButtonPosition,
+ playButtonProximityThreshold = 30,
+ maximumPossibleDistanceBetweenTwoMasses,
+ highScoreCookieKey = 'tetherHighScore',
+ highScore = storage.getItem(highScoreCookieKey) ?? 0,
+ musicMutedCookieKey = 'tetherMusicMuted',
+ lastDayCookieKey = 'tetherLastDate',
+ streakCountCookieKey = 'tetherStreakCount',
+ streakCount = storage.getItem(streakCountCookieKey) ?? 0,
+ subtitleText = "",
+ lastDate = new Date(Number(storage.getItem(lastDayCookieKey))),
+ lastTouchStart,
+ uidCookieKey = 'tetherId',
+ uid,
+ playerRGB = [20, 20, 200],
+ hslVal = 0,
+ paused = false,
+ shouldUnmuteImmediately = false,
+ cookieExpiryDate = new Date();
+
+if (window.location.pathname === '/source/') subtitleText = 'Source Development Mode. #OpenSource';
+else subtitleText = 'Swing around a ball and cause pure destruction.';
+
+window.addEventListener('offline', () => {
+ window.location.href = '/offline/';
+});
+
+cookieExpiryDate.setFullYear(cookieExpiryDate.getFullYear() + 50);
+var cookieSuffix = '; expires=' + cookieExpiryDate.toUTCString();
+
+function extend(base, sub) {
+ sub.prototype = Object.create(base.prototype);
+ sub.prototype.constructor = sub;
+ Object.defineProperty(sub.prototype, 'constructor', {
+ enumerable: false,
+ value: sub,
+ });
+}
+
+function choice(array) {
+ return array[Math.floor(Math.random() * array.length)];
+}
+
+function somewhereInTheViewport() {
+ return {
+ x: Math.random() * width,
+ y: Math.random() * height,
+ };
+}
+
+function somewhereJustOutsideTheViewport(buffer) {
+ var somewhere = somewhereInTheViewport();
+ var edgeSeed = Math.random();
+
+ if (edgeSeed < 0.25) somewhere.x = -buffer;
+ else if (edgeSeed < 0.5) somewhere.x = width + buffer;
+ else if (edgeSeed < 0.75) somewhere.y = -buffer;
+ else somewhere.y = height + buffer;
+
+ return somewhere;
+}
+
+function closestWithinViewport(position) {
+ var newPos = { x: position.x, y: position.y };
+ newPos = forXAndY([newPos, { x: 0, y: 0 }], forXAndY.theGreater);
+ newPos = forXAndY([newPos, { x: width, y: height }], forXAndY.theLesser);
+ return newPos;
+}
+
+function getAttributeFromAllObjs(objs, attr) {
+ var attrs = [];
+ for (var i = 0; i < objs.length; i++) {
+ attrs.push(objs[i][attr]);
+ }
+ return attrs;
+}
+
+function forXAndY(objs, func) {
+ return {
+ x: func.apply(null, getAttributeFromAllObjs(objs, 'x')),
+ y: func.apply(null, getAttributeFromAllObjs(objs, 'y')),
+ };
+}
+
+forXAndY.aPlusHalfB = function (a, b) {
+ return a + b * 5;
+};
+forXAndY.aPlusBTimesSpeed = function (a, b) {
+ return a + b * game.timeDelta;
+};
+forXAndY.subtract = function (a, b) {
+ return a - b;
+};
+forXAndY.invSubtract = function (a, b) {
+ return b - a;
+};
+forXAndY.theGreater = function (a, b) {
+ return a > b ? a : b;
+};
+forXAndY.theLesser = function (a, b) {
+ return a < b ? a : b;
+};
+forXAndY.add = function () {
+ var s = 0;
+ for (var i = 0; i < arguments.length; i++) s += arguments[i];
+ return s;
+};
+forXAndY.multiply = function () {
+ var p = 1;
+ for (var i = 0; i < arguments.length; i++) p *= arguments[i];
+ return p;
+};
+
+function randomisedVector(vector, potentialMagnitude) {
+ var angle = Math.random() * Math.PI * 2;
+ var magnitude = Math.random() * potentialMagnitude;
+ return forXAndY([vector, vectorAt(angle, magnitude)], forXAndY.add);
+}
+
+function getIntersection(line1, line2) {
+ var denominator,
+ a,
+ b,
+ numerator1,
+ numerator2,
+ result = {
+ x: null,
+ y: null,
+ onLine1: false,
+ onLine2: false,
+ };
+
+ denominator =
+ (line2[1].y - line2[0].y) * (line1[1].x - line1[0].x) -
+ (line2[1].x - line2[0].x) * (line1[1].y - line1[0].y);
+
+ if (denominator === 0) {
+ return result;
+ }
+
+ a = line1[0].y - line2[0].y;
+ b = line1[0].x - line2[0].x;
+ numerator1 = (line2[1].x - line2[0].x) * a - (line2[1].y - line2[0].y) * b;
+ numerator2 = (line1[1].x - line1[0].x) * a - (line1[1].y - line1[0].y) * b;
+ a = numerator1 / denominator;
+ b = numerator2 / denominator;
+
+ result.x = line1[0].x + a * (line1[1].x - line1[0].x);
+ result.y = line1[0].y + a * (line1[1].y - line1[0].y);
+
+ if (a > 0 && a < 1) {
+ result.onLine1 = true;
+ }
+ if (b > 0 && b < 1) {
+ result.onLine2 = true;
+ }
+ return result;
+}
+
+function pointInPolygon(point, polygon) {
+ var i, j;
+ var c = 0;
+ var numberOfPoints = polygon.length;
+ for (i = 0, j = numberOfPoints - 1; i < numberOfPoints; j = i++) {
+ if (
+ ((polygon[i].y <= point.y && point.y < polygon[j].y) ||
+ (polygon[j].y <= point.y && point.y < polygon[i].y)) &&
+ point.x <
+ ((polygon[j].x - polygon[i].x) * (point.y - polygon[i].y)) /
+ (polygon[j].y - polygon[i].y) +
+ polygon[i].x
+ ) {
+ c = !c;
+ }
+ }
+
+ return c;
+}
+
+function vectorMagnitude(vector) {
+ return Math.abs(
+ Math.pow(Math.pow(vector.x, 2) + Math.pow(vector.y, 2), 1 / 2),
+ );
+}
+
+function vectorAngle(vector) {
+ theta = Math.atan(vector.y / vector.x);
+ if (vector.x < 0) theta += Math.PI;
+ return theta;
+}
+
+function vectorAt(angle, magnitude) {
+ return {
+ x: Math.cos(angle) * magnitude,
+ y: Math.sin(angle) * magnitude,
+ };
+}
+
+function inverseVector(vector) {
+ var angle = vectorAngle(vector);
+ var mag = vectorMagnitude(vector);
+ return vectorAt(angle, 1 / mag);
+}
+
+function linesFromPolygon(polygon) {
+ var polyLine = [];
+ for (var i = 1; i < polygon.length; i++) {
+ polyLine.push([polygon[i - 1], polygon[i]]);
+ }
+ return polyLine;
+}
+
+function lineAngle(line) {
+ return vectorAngle({
+ x: line[1].x - line[0].x,
+ y: line[1].y - line[0].y,
+ });
+}
+
+function lineDelta(line) {
+ return forXAndY(line, forXAndY.invSubtract);
+}
+
+function rgbWithOpacity(rgb, opacity) {
+ var rgbStrings = [];
+ for (var i = 0; i < rgb.length; rgbStrings.push(rgb[i++].toFixed(0)));
+ return 'rgba(' + rgbStrings.join(',') + ',' + opacity.toFixed(2) + ')';
+}
+
+function hsl(hsl) {
+ return 'hsl(' + hsl + ', 100%, 50%)';
+}
+
+function draw(opts) {
+ for (var defaultKey in draw.defaults) {
+ if (!(defaultKey in opts)) opts[defaultKey] = draw.defaults[defaultKey];
+ }
+
+ if (DEBUG) {
+ for (var key in opts) {
+ if (!(key in draw.defaults)) throw key + ' is not a valid option to draw()';
+ }
+ }
+
+ ctx.fillStyle = opts.fillStyle;
+ ctx.strokeStyle = opts.strokeStyle;
+ ctx.lineWidth = opts.lineWidth;
+
+ ctx.beginPath();
+
+ if (opts.type === 'arc') draw.arc(opts);
+ else if (opts.type === 'line') draw.line(opts);
+ else if (opts.type === 'text') draw.text(opts);
+ else if (opts.type === 'rect') draw.rect(opts);
+ else if (opts.type === 'clear') draw.clear(opts);
+ else throw opts.type + ' is not an implemented draw type';
+
+ if (opts.fill) ctx.fill();
+ if (opts.stroke) ctx.stroke();
+}
+
+draw.defaults = {
+ type: null,
+ fill: false,
+ stroke: false,
+
+ linePaths: [],
+
+ arcCenter: undefined,
+ arcRadius: 0,
+ arcStart: 0,
+ arcFinish: 2 * Math.PI,
+
+ text: '',
+ textPosition: undefined,
+ fontFamily: 'Tulpen One',
+ fontFallback: 'sans-serif',
+ textAlign: 'center',
+ textBaseline: 'middle',
+ fontSize: 20,
+
+ rectBounds: [],
+
+ lineWidth: 1,
+ fillStyle: '#000',
+ strokeStyle: '#000',
+};
+
+draw.arc = function (opts) {
+ ctx.arc(
+ opts.arcCenter.x,
+ opts.arcCenter.y,
+ opts.arcRadius,
+ opts.arcStart,
+ opts.arcFinish,
+ );
+};
+
+draw.line = function (opts) {
+ for (var ipath = 0; ipath < opts.linePaths.length; ipath++) {
+ var path = opts.linePaths[ipath];
+
+ ctx.moveTo(path[0].x, path[0].y);
+
+ for (var ipos = 1; ipos < path.length; ipos++) {
+ var position = path[ipos];
+ ctx.lineTo(position.x, position.y);
+ }
+ }
+};
+
+draw.rect = function (opts) {
+ ctx.fillRect.apply(ctx, opts.rectBounds);
+};
+
+draw.text = function (opts) {
+ ctx.font =
+ opts.fontSize.toString() +
+ 'px "' +
+ opts.fontFamily +
+ '", ' +
+ opts.fontFallback;
+ ctx.textAlign = opts.textAlign;
+ ctx.textBaseline = opts.textBaseline;
+
+ ctx.fillText(opts.text, opts.textPosition.x, opts.textPosition.y);
+};
+
+draw.clear = function () {
+ ctx.clearRect(0, 0, width, height);
+};
+
+function scaleCanvas(ratio) {
+ canvas.width = width * ratio;
+ canvas.height = height * ratio;
+
+ ctx.scale(ratio, ratio);
+}
+
+var achievements = {
+ die: {
+ name: "You're coming with me",
+ description: 'Take solace in your mutual destruction',
+ },
+ introduction: {
+ name: 'How to play',
+ description: 'Die with one point',
+ },
+ kill: {
+ name: 'Weapon of choice',
+ description: 'Kill an enemy without dying yourself',
+ },
+ impact: {
+ name: 'Concussion',
+ description: 'Feel the impact',
+ },
+ quickdraw: {
+ name: 'Quick draw',
+ description: 'Kill an enemy within a few moments of it spawning',
+ },
+ omnicide: {
+ name: 'Omnicide',
+ description: 'Kill every type of enemy in one game',
+ },
+ panic: {
+ name: 'Panic',
+ description: 'Be alive while fifteen enemies are on screen',
+ },
+ lowRes: {
+ name: 'Cramped',
+ description:
+ 'Score ten points at 500x500px or less (currently ' +
+ width +
+ 'x' +
+ height +
+ ')',
+ },
+ handsFree: {
+ name: 'Hands-free',
+ description: 'Score five points in a row without moving the tether',
+ },
+};
+
+function initCanvas() {
+ var later24Hours = lastDate.getTime() + 86400000;
+ var later48Hours = lastDate.getTime() + 2 * 86400000;
+ var currentDate = new Date();
+
+ var streak = Number(storage.getItem(streakCountCookieKey));
+
+ if (
+ !Number(storage.getItem(lastDayCookieKey)) ||
+ Number.isNaN(lastDate)
+ ) {
+ saveCookie(lastDayCookieKey, currentDate.getTime());
+ saveCookie(streakCountCookieKey, 0);
+ } else if (
+ later48Hours > Number(new Date()) &&
+ Number(new Date()) > later24Hours
+ ) {
+ saveCookie(streakCountCookieKey, (streak += 1));
+ saveCookie(lastDayCookieKey, currentDate.getTime());
+ } else if (Number(new Date()) < later24Hours) {
+ } else {
+ saveCookie(streakCountCookieKey, 0);
+ saveCookie(lastDayCookieKey, currentDate.getTime());
+ }
+
+ switch (streak) {
+ case 0:
+ break;
+ case 1:
+ playerRGB = [206, 125, 165];
+ break;
+ case 2:
+ playerRGB = [50, 147, 165];
+ break;
+ case 3:
+ playerRGB = [223, 41, 53];
+ break;
+ case 4:
+ playerRGB = [223, 41, 53];
+ break;
+ case 5:
+ playerRGB = [39, 38, 53];
+ break;
+ case 6:
+ playerRGB = [255, 231, 76];
+ break;
+ case 7:
+ case 8:
+ case 9:
+ playerRGB = [15, 14, 14];
+ break;
+ default:
+ case 10:
+ playerRGB = 'Rainbow';
+ console.log('Congrats on your 10 day streak!!');
+ break;
+ }
+
+ width = window.innerWidth;
+ height = window.innerHeight;
+ muteButtonPosition = { x: 32, y: height - 28 };
+ playButtonPosition = { x: 32, y: height - 28 };
+
+ maximumPossibleDistanceBetweenTwoMasses = vectorMagnitude({
+ x: width,
+ y: height,
+ });
+
+ canvas = document.getElementById('game');
+ ctx = canvas.getContext('2d');
+
+ canvas.style.width = width.toString() + 'px';
+ canvas.style.height = height.toString() + 'px';
+
+ canvas.requestPointerLock =
+ canvas.requestPointerLock || canvas.mozRequestPointerLock;
+ document.exitPointerLock =
+ document.exitPointerLock || document.mozExitPointerLock;
+
+ for (var key in storage) {
+ var value = storage.getItem(key);
+ if (
+ achievements[key] ||
+ key === musicMutedCookieKey ||
+ key === highScoreCookieKey
+ ) {
+ saveCookie(key, value);
+ if (achievements[key]) {
+ achievements[key].unlocked = new Date(Number(value));
+ }
+ }
+ }
+
+ scaleCanvas(devicePixelRatio);
+}
+
+window.addEventListener('resize', function (event) {
+ canvas = document.getElementById('game');
+
+ width = window.innerWidth;
+ height = window.innerHeight;
+ maximumPossibleDistanceBetweenTwoMasses = vectorMagnitude({
+ x: width,
+ y: height,
+ });
+ muteButtonPosition = { x: 32, y: height - 28 };
+ playButtonPosition = { x: 32, y: height - 28 };
+ devicePixelRatio = window.devicePixelRatio || 1;
+
+ canvas.style.width = width + 'px';
+ canvas.style.height = height + 'px';
+
+ if (!game.started) {
+ game.tether.teleportTo({
+ x: width / 2,
+ y: (height / 3) * 2,
+ });
+ }
+ scaleCanvas(devicePixelRatio);
+});
+
+function timeToNextClaim() {
+ var deadline = lastDate.getTime() + 86400000;
+ var timeRemaining = deadline - new Date();
+ var formattedTime = new Date(timeRemaining);
+
+ if (formattedTime > 0) {
+ return `${
+ formattedTime.getHours() > 9 ? '' : '0'
+ }${formattedTime.getHours()}:${
+ formattedTime.getMinutes() > 9 ? '' : '0'
+ }${formattedTime.getMinutes()}:${
+ formattedTime.getSeconds() > 9 ? '' : '0'
+ }${formattedTime.getSeconds()}`;
+ } else {
+ return 'Right Now!';
+ }
+}
+
+function edgesOfCanvas() {
+ return linesFromPolygon([
+ { x: 0, y: 0 },
+ { x: 0, y: height },
+ { x: width, y: height },
+ { x: width, y: 0 },
+ { x: 0, y: 0 },
+ ]);
+}
+
+initCanvas();
+
+function Music() {
+ var self = this,
+ path;
+
+ if (INFO) path = '../tether_theme.mp3';
+ else path = '../tether_theme.mp3';
+
+ self.element = new Audio(path);
+
+ if (typeof self.element.loop === 'boolean') {
+ if (INFO) console.log('using element.loop for looping');
+ self.element.loop = true;
+ } else {
+ if (INFO) console.log('using event listener for looping');
+ self.element.addEventListener('ended', function () {
+ self.element.currentTime = 0;
+ });
+ }
+
+ self.timeSignature = 4;
+
+ if (shouldUnmuteImmediately) self.element.play();
+}
+
+Music.prototype = {
+ bpm: 90,
+ url: 'tether_theme.mp3',
+ delayCompensation: 0.03,
+
+ totalBeat: function () {
+ return ((this.element.currentTime + this.delayCompensation) / 60) * this.bpm;
+ },
+
+ measure: function () {
+ return this.totalBeat() / this.timeSignature;
+ },
+
+ beat: function () {
+ return music.totalBeat() % this.timeSignature;
+ },
+
+ timeSinceBeat: function () {
+ return this.beat() % 1;
+ },
+};
+
+function Mass() {
+ this.seed = Math.random();
+}
+
+Mass.prototype = {
+ position: { x: 0, y: 0 },
+ positionOnPreviousFrame: { x: 0, y: 0 },
+ velocity: { x: 0, y: 0 },
+ force: { x: 0, y: 0 },
+ mass: 1,
+ lubricant: 1,
+ radius: 0,
+ visibleRadius: null,
+ dashInterval: 1 / 8,
+ walls: false,
+ bounciness: 0,
+ rgb: [60, 60, 60],
+ reactsToForce: true,
+
+ journeySincePreviousFrame: function () {
+ return [this.positionOnPreviousFrame, this.position];
+ },
+
+ bounceInDimension: function (d, max) {
+ var distanceFromFarEdge = max - this.radius - this.position[d];
+ var distanceFromNearEdge = this.position[d] - this.radius;
+
+ if (distanceFromNearEdge < 0) {
+ this.velocity[d] *= -this.bounciness;
+ this.position[d] = distanceFromNearEdge * this.bounciness + this.radius;
+ this.bounceCallback();
+ } else if (distanceFromFarEdge < 0) {
+ this.velocity[d] *= -this.bounciness;
+ this.position[d] = max - distanceFromFarEdge * this.bounciness - this.radius;
+ this.bounceCallback();
+ }
+ },
+
+ bounceCallback: function () { },
+
+ collideWithWalls: function () {
+ if (!this.walls) return;
+ this.bounceInDimension('x', width);
+ this.bounceInDimension('y', height);
+ },
+
+ setPosition: function (position) {
+ this.positionOnPreviousFrame = this.position;
+ this.position = position;
+ },
+
+ teleportTo: function (position) {
+ this.positionOnPreviousFrame = position;
+ this.position = position;
+ },
+
+ reactToVelocity: function () {
+ this.setPosition(
+ forXAndY([this.position, this.velocity], forXAndY.aPlusBTimesSpeed),
+ );
+ this.collideWithWalls();
+ },
+
+ velocityDelta: function () {
+ var self = this;
+ return forXAndY([this.force], function (force) {
+ return force / self.mass;
+ });
+ },
+
+ reactToForce: function () {
+ var self = this;
+ var projectedVelocity = forXAndY(
+ [this.velocity, this.velocityDelta()],
+ forXAndY.aPlusBTimesSpeed,
+ );
+
+ this.velocity = forXAndY([projectedVelocity], function (projected) {
+ return projected * Math.pow(self.lubricant, game.timeDelta);
+ });
+
+ this.reactToVelocity();
+ },
+
+ step: function () {
+ if (this.reactsToForce) this.reactToForce();
+ },
+
+ getOpacity: function () {
+ var opacity;
+ if (!this.died) opacity = 1;
+ else opacity = 1 / Math.max(1, game.timeElapsed - this.died);
+ return opacity;
+ },
+
+ getCurrentColor: function () {
+ if (this.rgb === 'Rainbow') {
+ if (hslVal !== 360) hslVal++;
+ else hslVal = 0;
+ }
+
+ return this.rgb === 'Rainbow'
+ ? hsl(hslVal)
+ : rgbWithOpacity(this.rgb, this.getOpacity());
+ },
+
+ draw: function () {
+ var radius = this.radius;
+ if (this.visibleRadius !== null) radius = this.visibleRadius;
+
+ draw({
+ type: 'arc',
+ arcRadius: radius,
+ arcCenter: this.position,
+ fillStyle: this.getCurrentColor(),
+ fill: true,
+ });
+ },
+
+ drawDottedOutline: function () {
+ for (var i = 0; i < 1; i += this.dashInterval) {
+ var startAngle = game.timeElapsed / 100 + i * Math.PI * 2;
+ draw({
+ type: 'arc',
+ stroke: true,
+ strokeStyle: this.getCurrentColor(),
+ arcCenter: this.position,
+ arcStart: startAngle,
+ arcFinish: startAngle + Math.PI * this.dashInterval * 0.7,
+ arcRadius: this.radius,
+ });
+ }
+ },
+
+ explode: function () {
+ for (i = 0; i < 50; i++) {
+ var angle = Math.random() * Math.PI * 2;
+ var magnitude = Math.random() * 40;
+ var velocity = forXAndY(
+ [vectorAt(angle, magnitude), this.velocity],
+ forXAndY.add,
+ );
+ new FireParticle(this.position, velocity);
+ }
+ },
+
+ focusSegment: function (offset) {
+ var baseAngle = game.timeElapsed / 30 + Math.cos(game.timeElapsed / 10) * 0.2;
+
+ draw({
+ type: 'arc',
+ stroke: true,
+ arcCenter: this.position,
+ arcStart: baseAngle + offset,
+ arcFinish: baseAngle + Math.PI * 0.5 + offset,
+ arcRadius: 40 + Math.sin(game.timeElapsed / 10) * 10,
+ strokeStyle: rgbWithOpacity([0, 0, 0], 0.6),
+ });
+ },
+
+ focus: function () {
+ this.focusSegment(0);
+ this.focusSegment(Math.PI);
+ },
+};
+
+function BackgroundPart(i) {
+ Mass.call(this);
+ this.i = i;
+ this.baseRadius = (2 * Math.max(width, height)) / i;
+ this.radius = 1;
+ this.bounciness = 1;
+ this.velocity = vectorAt(Math.PI * 2 * Math.random(), i * Math.random());
+ this.teleportTo(somewhereInTheViewport());
+ this.walls = true;
+}
+extend(Mass, BackgroundPart);
+
+BackgroundPart.prototype.getCurrentColor = function () {
+ return this.color;
+};
+
+BackgroundPart.prototype.step = function () {
+ this.color = rgbWithOpacity([127, 127, 127], 0.005 * this.i);
+
+ if (game.clickShouldMute && music.element.paused) {
+ this.color = rgbWithOpacity([255, 255, 255], 0.05 * this.i);
+ this.visibleRadius = this.baseRadius + Math.random() * this.baseRadius;
+ } else if (!music.element.paused) {
+ this.visibleRadius = (1 / music.timeSinceBeat()) * 20 + this.baseRadius;
+ } else {
+ this.visibleRadius = this.baseRadius;
+ }
+
+ Mass.prototype.step.call(this);
+};
+
+function Background() {
+ this.parts = [];
+ for (var i = 0; i < 10; i++) {
+ this.parts.push(new BackgroundPart(i));
+ }
+}
+
+Background.prototype.draw = function () {
+ if (game.clickShouldMute && music.element.paused) {
+ draw({
+ type: 'rect',
+ rectBounds: [0, 0, width, height],
+ fillStyle: rgbWithOpacity([0, 0, 0], 1),
+ });
+ }
+
+ for (var i = 0; i < this.parts.length; this.parts[i++].draw());
+};
+
+Background.prototype.step = function () {
+ for (var i = 0; i < this.parts.length; this.parts[i++].step());
+};
+
+function Tether() {
+ Mass.call(this);
+ this.radius = 5;
+
+ this.locked = true;
+ this.unlockable = true;
+ this.rgb = playerRGB ?? [20, 20, 200];
+
+ this.teleportTo({
+ x: width / 2,
+ y: (height / 3) * 2,
+ });
+
+ this.lastInteraction = null;
+ this.pointsScoredSinceLastInteraction = 0;
+
+ var self = this;
+
+ document.addEventListener('mousemove', function (e) {
+ if (
+ self.lastInteraction === 'mouse' &&
+ document.pointerLockElement !== canvas
+ )
+ game.lastMousePosition = { x: e.layerX, y: e.layerY };
+ self.lastInteraction = 'mouse';
+ });
+
+ document.addEventListener('touchend', function (e) {
+ self.locked = true;
+ });
+
+ function exitTether() {
+ if (
+ document.pointerLockElement === canvas ||
+ document.mozPointerLockElement === canvas
+ )
+ self.locked = false;
+ else self.locked = true;
+ }
+
+ if ('onpointerlockchange' in document)
+ document.addEventListener('pointerlockchange', exitTether);
+ else if ('onmozpointerlockchange' in document)
+ document.addEventListener('mozpointerlockchange', exitTether);
+
+ function handleTouch(e) {
+ e.preventDefault();
+ self.lastInteraction = 'touch';
+ if (document.pointerLockElement) document.exitPointerLock();
+ touch = e.changedTouches[0];
+ game.lastMousePosition = { x: touch.clientX, y: touch.clientY };
+ }
+
+ document.addEventListener('touchstart', handleTouch, { passive: false });
+ document.addEventListener('touchmove', handleTouch, { passive: false });
+
+ return this;
+}
+extend(Mass, Tether);
+
+Tether.prototype.setPosition = function (position) {
+ if (this.lastInteraction !== 'mouse' || document.pointerLockElement === canvas)
+ Mass.prototype.setPosition.call(this, position);
+ if (this.position !== this.positionOnPreviousFrame) {
+ this.pointsScoredSinceLastInteraction = 0;
+ }
+};
+
+Tether.prototype.step = function () {
+ var leniency = this.lastInteraction === 'touch' ? 50 : 30;
+
+ if (
+ this.unlockable &&
+ vectorMagnitude(
+ forXAndY([this.position, game.lastMousePosition], forXAndY.subtract),
+ ) < leniency
+ ) {
+ if (canvas.requestPointerLock) canvas.requestPointerLock();
+ if (
+ !(this.lastInteraction !== 'mouse' || document.pointerLockElement === canvas)
+ )
+ return;
+
+ this.locked = false;
+
+ if (!game.started) {
+ game.start();
+ }
+ }
+
+ if (!this.locked) {
+ this.setPosition(closestWithinViewport(game.lastMousePosition));
+ } else {
+ this.setPosition(this.position);
+ }
+};
+
+Tether.prototype.draw = function () {
+ if (this.locked && this.unlockable) this.focus();
+ Mass.prototype.draw.call(this);
+};
+
+function Player(tether) {
+ Mass.call(this);
+ this.mass = 50;
+ this.onceGameHasStartedLubricant = 0.99;
+ this.lubricant = 1;
+ this.radius = 10;
+ this.walls = true;
+ this.teleportTo({
+ x: Math.min((width / 10) * 9, width / 2 + 200),
+ y: 5 * (height / 9),
+ });
+ this.velocity = { x: 0, y: -height / 80 };
+ this.bounciness = 0.4;
+
+ this.tether = tether;
+ this.rgb = playerRGB ?? [20, 20, 200];
+}
+extend(Mass, Player);
+
+Player.prototype.step = function () {
+ this.force = forXAndY(
+ [this.tether.position, this.position],
+ forXAndY.subtract,
+ );
+ Mass.prototype.step.call(this);
+};
+
+function Cable(tether, player) {
+ var self = this;
+
+ self.areaCoveredThisStep = function () {
+ return [
+ tether.positionOnPreviousFrame,
+ player.positionOnPreviousFrame,
+ player.position,
+ tether.position,
+ ];
+ };
+
+ self.line = function () {
+ return [tether.position, player.position];
+ };
+
+ self.draw = function () {
+ draw({
+ type: 'line',
+ stroke: true,
+ strokeStyle: `${
+ playerRGB === 'Rainbow'
+ ? `${hsl(hslVal)}`
+ : `rgba(${playerRGB[0] ?? 20}, ${playerRGB[1] ?? 20}, ${
+ playerRGB[2] ?? 200
+ }, 1)`
+ }`,
+ linePaths: [self.line()],
+ });
+
+ if (DEBUG) self.drawAreaCoveredThisStep();
+ };
+
+ self.drawAreaCoveredThisStep = function () {
+ draw({
+ type: 'line',
+ fill: true,
+ fillStyle: rgbWithOpacity([127, 127, 255], 0.3),
+ linePaths: [self.areaCoveredThisStep()],
+ });
+ };
+}
+
+function Enemy(opts) {
+ Mass.call(this);
+ this.died = null;
+ this.exhausts = [];
+ this.spawned = false;
+
+ this.spawnAt = opts.spawnAt;
+ this.wave = opts.wave;
+ this.target = this.getTarget();
+}
+extend(Mass, Enemy);
+
+Enemy.prototype.getTarget = function () {
+ return game.player;
+};
+
+Enemy.prototype.randomSpawnPosition = function () {
+ return somewhereInTheViewport(this.radius);
+};
+
+Enemy.prototype.getTargetVector = function () {
+ return forXAndY([this.target.position, this.position], forXAndY.subtract);
+};
+
+Enemy.prototype.step = function () {
+ if (
+ this.force.x !== 0 &&
+ this.force.y !== 0 &&
+ Math.random() < game.timeDelta * vectorMagnitude(this.velocityDelta())
+ ) {
+ new Exhaust(this);
+ }
+
+ Mass.prototype.step.call(this);
+};
+
+Enemy.prototype.die = function (playerDeservesAchievement) {
+ if (this.died) {
+ if (INFO) console.log('tried to kill enemy that already died');
+ return;
+ }
+ if (playerDeservesAchievement) {
+ unlockAchievement('kill');
+
+ var name = this.constructor.name;
+
+ if (game.enemyTypesKilled.indexOf(name) === -1) {
+ game.enemyTypesKilled.push(name);
+ if (INFO) console.log(game.enemyTypesKilled);
+ if (game.enemyTypesKilled.length === enemyPool.length) {
+ unlockAchievement('omnicide');
+ }
+ }
+
+ if (this.died - this.spawnAt < 5) unlockAchievement('quickdraw');
+ }
+ this.explode();
+ this.died = game.timeElapsed;
+ if (game.ended) return;
+
+ game.incrementScore(1);
+};
+
+Enemy.prototype.draw = function () {
+ if (DEBUG && !this.died) this.drawTargetVector();
+
+ Mass.prototype.draw.call(this);
+};
+
+Enemy.prototype.drawTargetVector = function () {
+ draw({
+ type: 'line',
+ stroke: true,
+ strokeStyle: rgbWithOpacity([255, 127, 127], 0.7),
+ linePaths: [[this.position, this.target.position]],
+ });
+};
+
+Enemy.prototype.drawWarning = function () {
+ var timeUntilSpawn =
+ (this.spawnAt - game.timeElapsed) / this.wave.spawnWarningDuration;
+
+ draw({
+ type: 'arc',
+ stroke: true,
+ arcCenter: this.position,
+ arcRadius:
+ (this.visibleRadius || this.radius) / 2 + Math.pow(timeUntilSpawn, 2) * 700,
+ lineWidth:
+ ((2 * (this.visibleRadius || this.radius)) / 2) *
+ Math.pow(1 - timeUntilSpawn, 3),
+ strokeStyle: rgbWithOpacity(
+ this.rgbWarning || this.rgb,
+ (1 - timeUntilSpawn) * this.getOpacity(),
+ ),
+ });
+};
+
+function Drifter(opts) {
+ Enemy.call(this, opts);
+ this.radius = 10;
+ this.rgb = [30, 150, 150];
+ this.thrustAngle = undefined;
+ this.walls = true;
+ this.bounciness = 1;
+ this.power = 0.3;
+ this.lubricant = 0.8;
+ this.curvature = Math.max(width, height);
+}
+extend(Enemy, Drifter);
+
+Drifter.prototype.getTarget = function () {
+ return game.tether;
+};
+
+Drifter.prototype.randomSpawnPosition = function () {
+ var somewhere = somewhereInTheViewport();
+ somewhere.x = (somewhere.x * 2) / 3 + width / 6;
+ somewhere.y = (somewhere.y * 2) / 3 + height / 6;
+ return somewhere;
+};
+
+Drifter.prototype.step = function () {
+ if (this.thrustAngle === undefined) {
+ this.thrustAngle = vectorAngle(this.getTargetVector());
+
+ var error = Math.random() + 1;
+ if (Math.random() > 0.5) error *= -1;
+ this.thrustAngle += error / 5;
+ }
+
+ if (!this.died) {
+ this.force = vectorAt(this.thrustAngle, this.power);
+ } else this.force = { x: 0, y: 0 };
+
+ Enemy.prototype.step.call(this);
+};
+
+Drifter.prototype.bounceCallback = function () {
+ this.thrustAngle = vectorAngle(this.velocity);
+};
+
+function Eye(opts) {
+ Enemy.call(this, opts);
+
+ var size = opts.size || 0.75 + Math.random() / 1.5;
+
+ this.mass = size * (1500 / maximumPossibleDistanceBetweenTwoMasses);
+
+ this.lubricant = 0.9;
+ this.radius = size * 10;
+ this.shadowRadius = this.radius + 3;
+ this.shadowOpacity = 0.5;
+ this.rgb = [255, 255, 255];
+ this.rgbWarning = [50, 50, 50];
+}
+extend(Enemy, Eye);
+
+Eye.prototype.step = function () {
+ if (!this.died) {
+ var targetVector = this.getTargetVector();
+ targetVectorMagnitude = vectorMagnitude(targetVector);
+ this.force = forXAndY([targetVector], function (target) {
+ return target * (1 / targetVectorMagnitude);
+ });
+ } else this.force = { x: 0, y: 0 };
+
+ Enemy.prototype.step.call(this);
+};
+
+Eye.prototype.getRelativeDistance = function () {
+ var targetVector = this.getTargetVector();
+ return vectorMagnitude(targetVector) / maximumPossibleDistanceBetweenTwoMasses;
+};
+
+Eye.prototype.getCalmness = function () {
+ return 1 / Math.pow(1 / this.getRelativeDistance(), 1 / 4);
+};
+
+Eye.prototype.drawWarning = function () {
+ var timeUntilSpawn =
+ (this.spawnAt - game.timeElapsed) / this.wave.spawnWarningDuration;
+
+ draw({
+ type: 'arc',
+ stroke: true,
+ lineWidth: ((2 * this.shadowRadius) / 2) * Math.pow(1 - timeUntilSpawn, 3),
+ strokeStyle: rgbWithOpacity(
+ this.rgbWarning || this.rgb,
+ (1 - timeUntilSpawn) * this.getOpacity() * this.shadowOpacity,
+ ),
+ arcCenter: this.position,
+ arcRadius: this.shadowRadius / 2 + Math.pow(timeUntilSpawn, 2) * 700,
+ });
+};
+
+Eye.prototype.getIrisColor = function () {
+ var red = 0;
+ if (Math.random() < Math.pow(1 - this.getCalmness(), 4) * game.timeDelta)
+ red = 255;
+ return rgbWithOpacity([red, 0, 0], this.getOpacity());
+};
+
+Eye.prototype.awakeness = function () {
+ var timeAlive = game.timeElapsed - this.spawnAt;
+ return 1 - 1 / (timeAlive / 3 + 1);
+};
+
+Eye.prototype.drawIris = function () {
+ var awakeness = this.awakeness();
+ var targetVector = this.getTargetVector();
+ var relativeDistance = this.getRelativeDistance();
+
+ var irisVector = vectorAt(
+ vectorAngle(targetVector),
+ awakeness * this.radius * Math.pow(relativeDistance, 1 / 2) * 0.7,
+ );
+
+ var centreOfIris = forXAndY([this.position, irisVector], forXAndY.add);
+
+ var irisRadius = ((this.radius * 1) / 3) * awakeness;
+
+ draw({
+ type: 'arc',
+ fill: true,
+ fillStyle: this.getIrisColor(),
+ arcCenter: centreOfIris,
+ arcRadius: irisRadius,
+ });
+};
+
+Eye.prototype.draw = function () {
+ draw({
+ type: 'arc',
+ fill: true,
+ fillStyle: rgbWithOpacity([0, 0, 0], this.getOpacity() * this.shadowOpacity),
+ arcCenter: this.position,
+ arcRadius: this.shadowRadius,
+ });
+
+ this.visibleRadius = this.radius * Math.pow(this.awakeness(), 1 / 6);
+ Enemy.prototype.draw.call(this);
+
+ if (this.died) return;
+
+ this.drawIris();
+};
+
+function Twitchy(opts) {
+ Enemy.call(this, opts);
+ this.charging = false;
+
+ this.mass = 100;
+ this.lubricant = 0.92;
+ this.chargeRate = 0.01;
+ this.dischargeRate = 0.1;
+ this.radius = 5;
+
+ this.fuel = 0.9;
+ this.rgbDischarging = [200, 30, 30];
+ this.rgbWarning = this.rgbDischarging;
+}
+extend(Enemy, Twitchy);
+
+Twitchy.prototype.step = function () {
+ if (this.died || this.charging) {
+ this.force = { x: 0, y: 0 };
+ if (this.charging) {
+ this.fuel += game.timeDelta * this.chargeRate;
+ if (this.fuel >= 1) {
+ this.fuel = 1;
+ this.charging = false;
+ }
+ }
+ } else {
+ this.force = this.getTargetVector();
+ this.fuel -= game.timeDelta * this.dischargeRate;
+
+ if (this.fuel <= 0) {
+ this.fuel = 0;
+ this.charging = true;
+ }
+ }
+
+ Enemy.prototype.step.call(this);
+};
+
+Twitchy.prototype.getCurrentColor = function () {
+ if (this.charging) {
+ var brightness = 255;
+ var whiteness = Math.pow(this.fuel, 1 / 40);
+
+ if (0.98 < this.fuel || (0.94 < this.fuel && this.fuel < 0.96)) {
+ brightness = 0;
+ }
+
+ this.rgb = [brightness, brightness * whiteness, brightness * whiteness];
+ } else this.rgb = this.rgbDischarging;
+
+ return Enemy.prototype.getCurrentColor.call(this);
+};
+
+Twitchy.prototype.draw = function () {
+ if (this.charging && this.fuel >= 0) {
+ draw({
+ type: 'arc',
+ fill: true,
+ fillStyle: rgbWithOpacity([30, 30, 30], this.getOpacity() * this.fuel),
+ arcRadius: (this.radius * 1.2) / this.fuel,
+ arcCenter: this.position,
+ });
+ }
+
+ Enemy.prototype.draw.call(this);
+};
+
+function Particle() {
+ Mass.call(this);
+ game.particles.push(this);
+}
+extend(Mass, Particle);
+Particle.prototype.isWorthDestroying = function () {
+ return Math.abs(this.velocity.x) < 0.001 && Math.abs(this.velocity.y) < 0.001;
+};
+
+function FireParticle(position, velocity) {
+ Particle.call(this);
+ this.lubricant = 0.9;
+ this.created = game.timeElapsed;
+ this.teleportTo(position);
+ this.velocity = velocity;
+ this.red = 1;
+ this.green = 1;
+ this.blue = 0;
+ this.opacity = 1;
+
+ this.initialIntensity = velocity.x * (2 * Math.random());
+}
+extend(Particle, FireParticle);
+
+FireParticle.prototype.getCurrentColor = function () {
+ var intensity = this.velocity.x / this.initialIntensity;
+ return rgbWithOpacity(
+ this.rgbForIntensity(intensity),
+ Math.pow(intensity, 0.25) * this.opacity,
+ );
+};
+
+FireParticle.prototype.rgbForIntensity = function (intensity) {
+ return [Math.pow(intensity, 0.2) * 255, intensity * 200, 0];
+};
+
+FireParticle.prototype.draw = function () {
+ if (Math.random() < 0.1 * game.timeDelta) return;
+
+ var timeAlive = game.timeElapsed - this.created;
+ var maturity = 1 - 1 / (timeAlive / 3 + 1);
+ var velocityButSmallerWhenYoung = forXAndY(
+ [this.velocity, { x: maturity, y: maturity }],
+ forXAndY.multiply,
+ );
+
+ draw({
+ type: 'line',
+ stroke: true,
+ strokeStyle: this.getCurrentColor(),
+ linePaths: [
+ [
+ this.position,
+ forXAndY([this.position, velocityButSmallerWhenYoung], forXAndY.aPlusHalfB),
+ ],
+ ],
+ });
+};
+
+function Exhaust(source) {
+ var position = source.position;
+
+ var delta = source.velocityDelta();
+ var baseVelocity = forXAndY([source.velocity, delta], function (v, d) {
+ return 0.3 * v - d * 20;
+ });
+
+ var deltaMagnitude = vectorMagnitude(delta);
+ var velocity = forXAndY([baseVelocity], function (b) {
+ return b * (1 + (Math.random() - 0.5) * (0.8 + deltaMagnitude * 0.1));
+ });
+
+ FireParticle.call(this, position, velocity);
+
+ this.opacity = 0.7;
+}
+extend(FireParticle, Exhaust);
+
+Exhaust.prototype.rgbForIntensity = function (intensity) {
+ return [intensity * 200, 50 + intensity * 100, 50 + intensity * 100];
+};
+
+function TeleportDust(source) {
+ var randomDelta = vectorAt(
+ Math.random() * Math.PI * 2,
+ Math.random() * source.radius * 0.1,
+ );
+
+ var velocityMultiplier = (Math.random() * 1) / 10;
+ var baseVelocity = forXAndY(
+ [source.teleportDelta, { x: velocityMultiplier, y: velocityMultiplier }],
+ forXAndY.multiply,
+ );
+ var velocity = forXAndY([baseVelocity, randomDelta], forXAndY.add);
+
+ var distanceFromStart = Math.random();
+ var vectorFromStart = forXAndY(
+ [source.teleportDelta, { x: distanceFromStart, y: distanceFromStart }],
+ forXAndY.multiply,
+ );
+ var basePosition = forXAndY([source.position, vectorFromStart], forXAndY.add);
+ var position = forXAndY([basePosition, randomDelta], forXAndY.add);
+
+ FireParticle.call(this, position, velocity);
+}
+extend(FireParticle, TeleportDust);
+
+TeleportDust.prototype.rgbForIntensity = function (intensity) {
+ return [100 + intensity * 100, intensity * 200, 60 + intensity * 150];
+};
+
+function Wave() {
+ this.enemies = [];
+ this.complete = false;
+ this.doneSpawningEnemies = false;
+ this.spawnWarningDuration = 50;
+ this.boredomCompensation = 0;
+ this.startedAt = game.timeElapsed;
+}
+
+Wave.prototype.step = function () {
+ this.spawnEnemies();
+
+ this.remainingLivingEnemies = 0;
+
+ for (var i = 0; i < this.enemies.length; i++) {
+ var enemy = this.enemies[i];
+ if (enemy.spawned) enemy.step();
+ else if (enemy.spawnAt <= game.timeElapsed) enemy.spawned = true;
+
+ if (!enemy.died) this.remainingLivingEnemies++;
+ }
+
+ if (this.remainingLivingEnemies >= 15) unlockAchievement('panic');
+ if (
+ this.doneSpawningEnemies &&
+ this.remainingLivingEnemies === 0 &&
+ !this.hasEnemiesWorthDrawing
+ )
+ this.complete = true;
+};
+
+Wave.prototype.draw = function () {
+ this.hasEnemiesWorthDrawing = false;
+
+ for (var i = 0; i < this.enemies.length; i++) {
+ var enemy = this.enemies[i];
+ var opacity = enemy.getOpacity();
+ if (opacity > 0.01) {
+ if (enemy.spawned) enemy.draw();
+ else enemy.drawWarning();
+
+ this.hasEnemiesWorthDrawing = true;
+ }
+ }
+};
+
+Wave.prototype.spawnEnemies = function () {
+ if (this.doneSpawningEnemies) return;
+
+ var remaininUnspawnedEnemies = 0;
+ var totalDelay = this.boredomCompensation;
+ var compensatedForBoredom = false;
+
+ for (var i = 0; i < this.spawns.length; i++) {
+ var spawn = this.spawns[i];
+
+ totalDelay += spawn.delay;
+
+ if (spawn.spawned) continue;
+
+ var timeUntilSpawn = totalDelay - (game.timeElapsed - this.startedAt);
+
+ if (!compensatedForBoredom && this.remainingLivingEnemies === 0) {
+ compensatedForBoredom = true;
+ this.boredomCompensation += timeUntilSpawn;
+ timeUntilSpawn -= this.boredomCompensation;
+ }
+
+ if (timeUntilSpawn <= 0) {
+ var opts = spawn.opts || {};
+
+ opts.spawnAt = game.timeElapsed + this.spawnWarningDuration;
+ opts.wave = this;
+
+ var enemy = new spawn.type(opts);
+
+ if (spawn.pos) {
+ enemy.teleportTo({
+ x: spawn.pos[0] * width,
+ y: spawn.pos[1] * height,
+ });
+ } else enemy.teleportTo(enemy.randomSpawnPosition());
+
+ this.enemies.push(enemy);
+
+ spawn.spawned = true;
+ } else {
+ remaininUnspawnedEnemies++;
+ }
+ }
+
+ if (remaininUnspawnedEnemies === 0) this.doneSpawningEnemies = true;
+};
+
+function tutorialFor(enemyType, enemyOpts) {
+ function Tutorial() {
+ Wave.call(this);
+ this.spawns = [
+ {
+ delay: 0,
+ type: enemyType,
+ pos: [1 / 2, 1 / 5],
+ opts: enemyOpts || {},
+ },
+ ];
+ }
+ extend(Wave, Tutorial);
+ return Tutorial;
+}
+
+function aBunchOf(enemyType, count, interval) {
+ function ABunch() {
+ Wave.call(this);
+ this.spawns = [];
+
+ for (var i = 0; i < count; i++) {
+ this.spawns.push({
+ delay: interval * (i + 1),
+ type: enemyType,
+ });
+ }
+ }
+ extend(Wave, ABunch);
+ return ABunch;
+}
+
+function autoWave(difficulty) {
+ var totalSpawns;
+ var localEnemyPool;
+
+ if (difficulty % 2) {
+ totalSpawns = 15 + difficulty;
+ localEnemyPool = enemyPool;
+ } else {
+ localEnemyPool = [enemyPool[(difficulty / 2) % enemyPool.length]];
+ totalSpawns = 10 + difficulty;
+ }
+
+ function AutoWave() {
+ Wave.call(this);
+ this.spawns = [];
+
+ for (var i = 0; i < totalSpawns; i++) {
+ this.spawns.push({
+ delay: (Math.pow(Math.random(), 1 / 2) * 400) / (difficulty + 7),
+ type: choice(localEnemyPool),
+ });
+ }
+ }
+
+ extend(Wave, AutoWave);
+ return AutoWave;
+}
+
+function saveCookie(key, value) {
+ storage.setItem(key, value);
+ document.cookie = key + '=' + value + cookieSuffix;
+}
+
+function unlockAchievement(slug) {
+ var achievement = achievements[slug];
+ if (!achievement.unlocked) {
+ achievement.unlocked = new Date();
+ saveCookie(slug, achievement.unlocked.getTime().toString());
+ }
+}
+
+function logScore(score) {
+ if (score > highScore) {
+ highScore = score;
+ saveCookie(highScoreCookieKey, score.toString());
+ }
+}
+
+function getUnlockedAchievements(invert) {
+ var unlockedAchievements = [];
+ invert = invert || false;
+
+ for (var key in achievements) {
+ var achievement = achievements[key];
+ if (invert ^ (achievement.unlocked !== undefined))
+ unlockedAchievements.push(achievement);
+ }
+
+ return unlockedAchievements;
+}
+
+function getLockedAchievements() {
+ return getUnlockedAchievements(true);
+}
+
+function Game() {
+ var self = this;
+
+ self.lastMousePosition = { x: NaN, y: NaN };
+
+ self.reset = function (waveIndex) {
+ if (document.pointerLockElement) document.exitPointerLock();
+
+ self.background = new Background();
+ self.ended = null;
+ self.score = 0;
+ self.enemyTypesKilled = [];
+ self.lastPointScoredAt = 0;
+ self.timeElapsed = 0;
+ self.normalSpeed = 0.04;
+ self.slowSpeed = self.normalSpeed / 100;
+ self.setSpeed(self.normalSpeed);
+
+ self.started = false;
+
+ self.waveIndex = waveIndex || 0;
+ self.waves = [
+ tutorialFor(Drifter),
+ aBunchOf(Drifter, 2, 5),
+
+ tutorialFor(Eye, { size: 1.5 }),
+ aBunchOf(Eye, 4, 100),
+ aBunchOf(Eye, 5, 10),
+
+ tutorialFor(Twitchy),
+ aBunchOf(Twitchy, 4, 50),
+ aBunchOf(Twitchy, 5, 10),
+ ];
+ self.wave = undefined;
+
+ self.particles = [];
+
+ self.tether = new Tether();
+ self.player = new Player(self.tether);
+ self.cable = new Cable(self.tether, self.player);
+ };
+
+ self.setSpeed = function (speed) {
+ self.speed = speed;
+ };
+
+ self.start = function () {
+ self.tether.locked = false;
+ self.player.lubricant = self.player.onceGameHasStartedLubricant;
+ self.started = true;
+ self.timeElapsed = 0;
+ };
+
+ self.pickNextWave = function () {
+ var waveType = self.waves[self.waveIndex++];
+
+ if (waveType === undefined) {
+ waveType = autoWave(self.waveIndex - self.waves.length);
+ }
+
+ self.wave = new waveType();
+ };
+
+ self.incrementScore = function (incr) {
+ self.lastPointScoredAt = self.timeElapsed;
+ self.score += incr;
+ self.tether.pointsScoredSinceLastInteraction += incr;
+
+ if (self.score >= 10 && width <= 500 && height <= 500) {
+ unlockAchievement('lowRes');
+ }
+
+ if (self.tether.pointsScoredSinceLastInteraction >= 5) {
+ unlockAchievement('handsFree');
+ }
+ };
+
+ self.getIntensity = function () {
+ return 1 / (1 + (self.timeElapsed - self.lastPointScoredAt));
+ };
+
+ self.stepParticles = function () {
+ for (var i = 0; i < self.particles.length; i++) {
+ if (self.particles[i] === undefined) {
+ continue;
+ } else if (self.particles[i].isWorthDestroying()) {
+ delete self.particles[i];
+ } else {
+ self.particles[i].step();
+ }
+ }
+ };
+
+ self.step = function () {
+ if (DEBUG) draw({ type: 'clear' });
+
+ var now = new Date().getTime();
+
+ if (!self.lastStepped) {
+ self.lastStepped = now;
+ return;
+ } else {
+ self.realTimeDelta = now - self.lastStepped;
+
+ self.timeDelta = Math.min(self.realTimeDelta, 1000 / 20) * self.speed;
+
+ self.timeElapsed += self.timeDelta;
+ self.lastStepped = now;
+ }
+
+ if (isNaN(self.lastMousePosition.x)) {
+ self.proximityToMuteButton = maximumPossibleDistanceBetweenTwoMasses;
+ self.proximityToPlayButton = maximumPossibleDistanceBetweenTwoMasses;
+ } else {
+ self.proximityToMuteButton = vectorMagnitude(
+ forXAndY([muteButtonPosition, self.lastMousePosition], forXAndY.subtract),
+ );
+ self.proximityToPlayButton = vectorMagnitude(
+ forXAndY([playButtonPosition, self.lastMousePosition], forXAndY.subtract),
+ );
+ }
+ self.clickShouldMute =
+ (!self.started || self.ended) &&
+ self.proximityToMuteButton < muteButtonProximityThreshold
+ ? true
+ : false;
+ self.clickShouldPlay =
+ self.started &&
+ !self.ended &&
+ self.proximityToPlayButton < playButtonProximityThreshold
+ ? true
+ : false;
+ if (self.clickShouldMute !== canvas.classList.contains('buttonhover'))
+ canvas.classList.toggle('buttonhover');
+ if (self.clickShouldPlay !== canvas.classList.contains('buttonhover'))
+ canvas.classList.toggle('buttonhover');
+
+ self.background.step();
+ self.tether.step();
+ self.player.step();
+
+ if (self.started) {
+ if (self.wave === undefined || self.wave.complete) self.pickNextWave();
+ self.wave.step();
+
+ if (!self.ended) self.checkForEnemyContact();
+ self.checkForCableContact();
+ }
+
+ self.stepParticles();
+
+ self.draw();
+ };
+
+ self.checkForCableContact = function () {
+ var cableAreaCovered = self.cable.areaCoveredThisStep();
+
+ for (var i = 0; i < self.wave.enemies.length; i++) {
+ var enemy = self.wave.enemies[i];
+
+ if (enemy.died || !enemy.spawned) {
+ continue;
+ }
+
+ var journey = enemy.journeySincePreviousFrame();
+ var cableLines = linesFromPolygon(cableAreaCovered);
+
+ if (pointInPolygon(enemy.position, cableAreaCovered)) {
+ enemy.die(true);
+ continue;
+ }
+
+ for (var ci = 0; ci < cableLines.length; ci++) {
+ var intersection = getIntersection(journey, cableLines[ci]);
+
+ if (intersection.onLine1 && intersection.onLine2) {
+ enemy.position = intersection;
+ enemy.die(true);
+ break;
+ }
+ }
+ }
+ };
+
+ self.checkForEnemyContactWith = function (mass) {
+ var massPositionDelta = lineDelta([
+ mass.positionOnPreviousFrame,
+ mass.position,
+ ]);
+
+ var colChecks = [];
+
+ for (var i = 0; i < self.wave.enemies.length; i++) {
+ var enemy = self.wave.enemies[i];
+
+ if (enemy.died || !enemy.spawned) {
+ continue;
+ }
+
+ var enemyPositionDelta = lineDelta([
+ enemy.positionOnPreviousFrame,
+ enemy.position,
+ ]);
+
+ for (
+ var progress = 0;
+ progress < 1;
+ progress +=
+ Math.min(enemy.radius, mass.radius) /
+ (3 *
+ Math.max(
+ enemyPositionDelta.x,
+ enemyPositionDelta.y,
+ massPositionDelta.x,
+ massPositionDelta.y,
+ 1,
+ ))
+ ) {
+ enemyPosition = {
+ x: enemy.positionOnPreviousFrame.x + enemyPositionDelta.x * progress,
+ y: enemy.positionOnPreviousFrame.y + enemyPositionDelta.y * progress,
+ };
+
+ massPosition = {
+ x: mass.positionOnPreviousFrame.x + massPositionDelta.x * progress,
+ y: mass.positionOnPreviousFrame.y + massPositionDelta.y * progress,
+ };
+
+ if (INFO) this.collisionChecks += 1;
+ if (DEBUG) colChecks.push([enemyPosition, massPosition]);
+
+ var distance = lineDelta([enemyPosition, massPosition]);
+
+ if (
+ Math.pow(distance.x, 2) + Math.pow(distance.y, 2) <
+ Math.pow(enemy.radius + mass.radius, 2)
+ ) {
+ enemy.position = enemyPosition;
+ mass.position = massPosition;
+ enemy.die(false);
+
+ if (mass === this.player) {
+ var relativeVelocity = lineDelta([mass.velocity, enemy.velocity]);
+ var impact =
+ vectorMagnitude(relativeVelocity) /
+ maximumPossibleDistanceBetweenTwoMasses;
+
+ if (impact > 0.04) unlockAchievement('impact');
+ if (INFO) console.log('impact: ' + impact.toString());
+ }
+
+ return mass;
+ }
+ }
+ }
+
+ if (DEBUG)
+ draw({
+ type: 'line',
+ stroke: true,
+ linePaths: colChecks,
+ strokeStyle: rgbWithOpacity([0, 127, 0], 0.3),
+ });
+ };
+
+ self.checkForEnemyContact = function () {
+ if (INFO) this.collisionChecks = 0;
+ var deadMass =
+ self.checkForEnemyContactWith(self.tether) ||
+ self.checkForEnemyContactWith(self.player);
+ if (deadMass) {
+ deadMass.rgb = [200, 20, 20];
+ deadMass.explode();
+ unlockAchievement('die');
+ if (game.score === 1) unlockAchievement('introduction');
+ game.end();
+ }
+ };
+
+ self.drawScore = function () {
+ if (self.score === 0) return;
+
+ var intensity = self.getIntensity();
+
+ draw({
+ type: 'text',
+ text: self.score.toString(),
+ fontSize: intensity * height * 5,
+ fillStyle: rgbWithOpacity([0, 0, 0], intensity),
+ textPosition: { x: width / 2, y: height / 2 },
+ });
+ };
+
+ self.drawParticles = function () {
+ for (var i = 0; i < this.particles.length; i++) {
+ if (this.particles[i] !== undefined) {
+ this.particles[i].draw();
+ }
+ }
+ };
+
+ self.drawLogo = function () {
+ var opacity = game.started ? Math.pow(1 - game.timeElapsed / 50, 3) : 1;
+ if (opacity < 0.001) return;
+
+ draw({
+ type: 'text',
+ text: 'tether!',
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ fontSize: 100,
+ textPosition: {
+ x: width / 2,
+ y: height / 3,
+ },
+ });
+
+ draw({
+ type: 'text',
+ text: subtitleText ?? 'Swing around a ball and cause pure destruction.',
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ fontSize: 30,
+ textPosition: {
+ x: width / 2,
+ y: height / 3 + 55,
+ },
+ });
+
+ draw({
+ type: 'text',
+ text:
+ ({ touch: 'tap', mouse: 'click' }[self.tether.lastInteraction] ?? 'click') +
+ ' to start',
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ fontSize: 24,
+ textPosition: {
+ x: width / 2,
+ y: (height / 4) * 3 + 80,
+ },
+ });
+ };
+
+ self.drawRestartTutorial = function () {
+ if (!self.ended) return;
+
+ var opacity = -Math.sin((game.timeElapsed - game.ended) * 3);
+ if (opacity < 0) opacity = 0;
+
+ var fontSize = Math.min(width / 5, height / 8);
+
+ draw({
+ type: 'text',
+ text:
+ ({ touch: 'tap', mouse: 'click' }[self.tether.lastInteraction] ?? 'click') +
+ ' to retry',
+ fontSize: fontSize,
+ textPosition: { x: width / 2, y: height / 2 - fontSize / 2 },
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ });
+ };
+
+ self.drawAchievementNotifications = function () {
+ var now = new Date().getTime();
+ var recentAchievements = [];
+ var animationDuration = 7000;
+
+ for (var slug in achievements) {
+ var achievement = achievements[slug];
+ if (achievement.unlocked === undefined) continue;
+
+ var unlocked = achievement.unlocked.getTime();
+
+ if (now > unlocked && now < unlocked + animationDuration) {
+ recentAchievements.push(achievement);
+ }
+ }
+
+ for (var i = 0; i < recentAchievements.length; i++) {
+ var recentAchievement = recentAchievements[i];
+ var progress = (now - recentAchievement.unlocked) / animationDuration;
+
+ var visibility = 1;
+ var buffer = 0.2;
+
+ var easing = 6;
+
+ if (progress < buffer) visibility = Math.pow(progress / buffer, 1 / easing);
+ else if (progress > 1 - buffer)
+ visibility = Math.pow((1 - progress) / buffer, easing);
+
+ var sink = -50 * (1 - visibility);
+ var notificationHeight = 60;
+ var baseNotificationHeight = 20 + notificationHeight * i;
+
+ var drawArgs = {
+ type: 'text',
+ text: 'Achievement Unlocked',
+ textAlign: 'right',
+ textBaseline: 'top',
+ fillStyle: rgbWithOpacity([0, 0, 0], visibility),
+ fontFamily: 'Quantico',
+ fontSize: 17,
+ textPosition: {
+ x: width - 25,
+ y: visibility * baseNotificationHeight + sink,
+ },
+ };
+
+ draw(drawArgs);
+
+ drawArgs.fontSize = 25;
+ drawArgs.text = recentAchievement.name;
+ drawArgs.textPosition = {
+ x: width - 25,
+ y: 19 + visibility * baseNotificationHeight + sink,
+ };
+
+ draw(drawArgs);
+ }
+ };
+
+ self.drawAchievements = function (
+ achievementList,
+ fromBottom,
+ fromRight,
+ headingText,
+ fillStyle,
+ ) {
+ if (achievementList.length === 0) return fromBottom;
+
+ var drawOpts = {
+ type: 'text',
+ fillStyle: fillStyle,
+ textAlign: 'right',
+ fontFamily: 'Quantico',
+ textBaseline: 'alphabetic',
+ };
+ var xPos = width - fromRight;
+
+ for (var i = 0; i < achievementList.length; i++) {
+ var achievement = achievementList[i];
+
+ drawOpts.text = achievement.name;
+ drawOpts.fontSize = 18;
+ drawOpts.textPosition = { x: xPos, y: height - fromBottom - 16 };
+ draw(drawOpts);
+
+ drawOpts.text = achievement.description;
+ drawOpts.fontSize = 13;
+ drawOpts.textPosition = { x: xPos, y: height - fromBottom };
+ draw(drawOpts);
+
+ fromBottom += 45;
+ }
+
+ drawOpts.text = headingText;
+ drawOpts.fontSize = 20;
+ drawOpts.textPosition = { x: xPos, y: height - fromBottom };
+ draw(drawOpts);
+
+ fromBottom += 55;
+ return fromBottom;
+ };
+
+ self.drawPauseMessage = function () {
+ var fontSize = Math.min(width / 5, height / 8);
+ draw({
+ type: 'text',
+ text:
+ ({ touch: 'tap', mouse: 'click' }[self.tether.lastInteraction] ?? 'click') +
+ ' to unpause',
+ fillStyle: '#000',
+ fontSize: fontSize,
+ textPosition: {
+ x: width / 2,
+ y: height / 2 - fontSize / 2,
+ },
+ });
+ };
+
+ self.drawAchievementUI = function () {
+ var unlockedAchievements = getUnlockedAchievements();
+ if (unlockedAchievements.length > 0) {
+ var indicatedPosition = { x: 0, y: 0 };
+ if (isNaN(game.lastMousePosition.x)) {
+ indicatedPosition = { x: 0, y: 0 };
+ } else {
+ indicatedPosition = game.lastMousePosition;
+ }
+ var distanceFromCorner = vectorMagnitude(
+ lineDelta([indicatedPosition, { x: width, y: height }]),
+ );
+ var distanceRange = [
+ maximumPossibleDistanceBetweenTwoMasses / 10,
+ maximumPossibleDistanceBetweenTwoMasses / 4,
+ ];
+ var hintOpacity;
+
+ if (distanceFromCorner > distanceRange[1]) hintOpacity = 1;
+ else if (distanceFromCorner > distanceRange[0])
+ hintOpacity =
+ (distanceFromCorner - distanceRange[0]) /
+ (distanceRange[1] - distanceRange[0]);
+ else hintOpacity = 0;
+
+ var listingOpacity = 1 - hintOpacity;
+
+ draw({
+ type: 'text',
+ text: 'Achievements…',
+ fillStyle: (fillStyle = rgbWithOpacity([0, 0, 0], hintOpacity)),
+ fontSize: 16,
+ textPosition: { x: width - 5, y: height - 8 },
+ textAlign: 'right',
+ textBaseline: 'alphabetic',
+ fontFamily: 'Quantico',
+ });
+
+ if (highScore) {
+ draw({
+ type: 'text',
+ text: 'Best Score: ' + highScore.toString(),
+ fillStyle: (fillStyle = rgbWithOpacity([0, 0, 0], hintOpacity)),
+ fontSize: 16,
+ textPosition: { x: width - 6, y: height - 56 },
+ textAlign: 'right',
+ textBaseline: 'bottom',
+ fontFamily: 'Quantico',
+ });
+ }
+
+ draw({
+ type: 'text',
+ text: 'Login Streak: ' + streakCount.toString(),
+ fillStyle: (fillStyle = rgbWithOpacity([0, 0, 0], hintOpacity)),
+ fontSize: 16,
+ textPosition: { x: width - 6, y: height - 38 },
+ textAlign: 'right',
+ textBaseline: 'bottom',
+ fontFamily: 'Quantico',
+ });
+
+ draw({
+ type: 'text',
+ text: 'Next Day: ' + timeToNextClaim(),
+ fillStyle: (fillStyle = rgbWithOpacity([0, 0, 0], hintOpacity)),
+ fontSize: 16,
+ textPosition: { x: width - 6, y: height - 20 },
+ textAlign: 'right',
+ textBaseline: 'bottom',
+ fontFamily: 'Quantico',
+ });
+
+ draw({
+ type: 'rect',
+ rectBounds: [0, 0, width, height],
+ fillStyle: rgbWithOpacity([255, 255, 255], listingOpacity * 0.9),
+ });
+
+ var heightNeeded = 500;
+ var widthNeeded = 500;
+ var fromBottom =
+ ((game.lastMousePosition.y - height) / height) * heightNeeded + 40;
+ var fromRight =
+ ((game.lastMousePosition.x - width) / width) * widthNeeded + 35;
+ fromBottom = this.drawAchievements(
+ getLockedAchievements(),
+ fromBottom,
+ fromRight,
+ 'Locked',
+ rgbWithOpacity([0, 0, 0], listingOpacity * 0.5),
+ );
+ this.drawAchievements(
+ unlockedAchievements,
+ fromBottom,
+ fromRight,
+ 'Unlocked',
+ rgbWithOpacity([0, 0, 0], listingOpacity),
+ );
+ }
+ };
+
+ self.eventShouldMute = function (e) {
+ var position;
+
+ if (e.changedTouches) {
+ var touch = e.changedTouches[0];
+ position = { x: touch.pageX, y: touch.pageY };
+ } else {
+ position = { x: e.layerX, y: e.layerY };
+ }
+
+ return self.positionShouldMute(position);
+ };
+
+ self.positionShouldMute = function (position) {
+ if (self.started || self.ended) return false;
+ self.proximityToMuteButton = vectorMagnitude(
+ forXAndY([muteButtonPosition, position], forXAndY.subtract),
+ );
+ return self.proximityToMuteButton < muteButtonProximityThreshold;
+ };
+
+ self.eventShouldPlay = function (e) {
+ var position;
+
+ if (e.changedTouches) {
+ var touch = e.changedTouches[0];
+ position = { x: touch.pageX, y: touch.pageY };
+ } else {
+ position = game.lastMousePosition || { x: e.layerX, y: e.layerY };
+ }
+
+ return self.positionShouldPlay(position);
+ };
+
+ self.positionShouldPlay = function (position) {
+ if (!(self.started && !self.ended)) return false;
+ if (paused) return true;
+ self.proximityToPlayButton = vectorMagnitude(
+ forXAndY([playButtonPosition, position], forXAndY.subtract),
+ );
+ return self.proximityToPlayButton < playButtonProximityThreshold;
+ };
+
+ self.drawMuteButton = function () {
+ if (!self.clickShouldMute && music.element.paused) {
+ xNoise = (Math.random() - 0.5) * (500 / self.proximityToMuteButton);
+ yNoise = (Math.random() - 0.5) * (500 / self.proximityToMuteButton);
+ visiblePosition = {
+ x: xNoise + muteButtonPosition.x,
+ y: yNoise + muteButtonPosition.y + Math.sin(new Date().getTime() / 250) * 3,
+ };
+ } else {
+ visiblePosition = { x: muteButtonPosition.x, y: muteButtonPosition.y };
+ }
+
+ if (!music.element.paused) {
+ visiblePosition.x = visiblePosition.x - 5;
+ visiblePosition.y = visiblePosition.y - 2;
+ }
+
+ var opacity = 1;
+
+ if (self.clickShouldMute && !music.element.paused) opacity = 0.5;
+
+ draw({
+ type: 'text',
+ text: music.element.paused ? '\uf025' : '\uf026',
+ fontFamily: 'FontAwesome',
+ fontSize: 30,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ textPosition: visiblePosition,
+ });
+ };
+
+ self.drawPlayButton = function () {
+ if (!self.clickShouldPlay && paused) {
+ xNoise = (Math.random() - 0.5) * (500 / self.proximityToPlayButton);
+ yNoise = (Math.random() - 0.5) * (500 / self.proximityToPlayButton);
+ visiblePosition = {
+ x: xNoise + playButtonPosition.x,
+ y: yNoise + playButtonPosition.y + Math.sin(new Date().getTime() / 250) * 3,
+ };
+ } else {
+ visiblePosition = { x: playButtonPosition.x, y: playButtonPosition.y };
+ }
+
+ var opacity = 1;
+
+ if (self.clickShouldPlay && !paused) opacity = 0.5;
+
+ draw({
+ type: 'text',
+ text: paused ? '\uf04b' : '\uf04c',
+ fontFamily: 'FontAwesome',
+ fontSize: 30,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ fillStyle: rgbWithOpacity([0, 0, 0], opacity),
+ textPosition: visiblePosition,
+ });
+ };
+
+ self.drawInfo = function () {
+ var fromBottom = 7;
+ var info = {
+ beat: Math.floor(music.beat()),
+ measure: Math.floor(music.measure()) + 1,
+ time: self.timeElapsed.toFixed(2),
+ fps: (1000 / self.realTimeDelta).toFixed(),
+ score: game.score,
+ };
+
+ if (self.started) {
+ info.wave = this.waveIndex.toString() + ' - ' + this.wave.constructor.name;
+ info.colchecks = self.collisionChecks.toFixed();
+ }
+
+ for (var key in info) {
+ draw({
+ type: 'text',
+ text: key + ': ' + info[key],
+ fontFamily: 'Monaco',
+ fontFallback: 'monospace',
+ fontSize: 12,
+ textAlign: 'left',
+ textBaseline: 'alphabetic',
+ fillStyle: rgbWithOpacity([0, 0, 0], 1),
+ textPosition: { x: 5, y: height - fromBottom },
+ });
+
+ fromBottom += 15;
+ }
+ };
+
+ self.draw = function () {
+ if (!DEBUG) draw({ type: 'clear' });
+
+ self.background.draw();
+ self.drawScore();
+ self.drawParticles();
+
+ if (self.started) self.wave.draw();
+ self.cable.draw();
+ self.tether.draw();
+ self.player.draw();
+
+ self.drawLogo();
+ self.drawRestartTutorial();
+
+ self.drawAchievementNotifications();
+
+ if (!self.started || self.ended) self.drawMuteButton();
+ if (self.started && !self.ended) self.drawPlayButton();
+
+ if ((self.tether.lastInteraction === 'mouse' && self.ended) || !self.started)
+ self.drawAchievementUI();
+
+ if (INFO) self.drawInfo();
+ };
+
+ self.end = function () {
+ if (document.pointerLockElement) document.exitPointerLock();
+ logScore(self.score);
+ self.ended = self.timeElapsed;
+ self.tether.locked = true;
+ self.tether.unlockable = false;
+ self.setSpeed(self.slowSpeed);
+ };
+
+ self.reset(0);
+}
+
+var enemyPool = [Drifter, Eye, Twitchy];
+
+music = new Music();
+game = new Game();
+
+function handleClick(e) {
+ if (game.eventShouldMute(e)) {
+ if (music.element.paused) {
+ console.log('play');
+ music.element.play();
+ saveCookie(musicMutedCookieKey, 'true');
+ } else {
+ console.log('pause');
+ music.element.pause();
+ saveCookie(musicMutedCookieKey, 'false');
+ }
+ } else if (game.eventShouldPlay(e)) {
+ paused = !paused;
+ } else if (game.ended) {
+ game.reset(0);
+ }
+}
+
+var konamiLength = 0;
+var konamiSequence = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'KeyB', 'KeyA', 'Space'];
+
+function konamiSeq(requiredKey, givenKey) {
+ if (requiredKey === givenKey) konamiLength++;
+ else konamiLength = 0;
+
+ if (konamiLength === 11) {
+ subtitleText = 'Special Cheats Activated. Have fun!';
+ playerRGB = 'Rainbow';
+ }
+}
+
+function handleKey(e) {
+ konamiSeq(konamiSequence[konamiLength], e.code);
+ if (self.started && !self.ended && e.code === 'KeyP') paused = !paused;
+}
+
+document.addEventListener('click', handleClick);
+document.addEventListener('keydown', handleKey);
+
+canvas.addEventListener('mousemove', function (e) {
+ if (game.tether.lastInteraction === 'touch' && document.pointerLockElement)
+ document.exitPointerLock();
+ else if (document.pointerLockElement === canvas) {
+ if (game.tether.locked) game.tether.locked = false;
+
+ game.lastMousePosition.x += e.movementX;
+ game.lastMousePosition.y += e.movementY;
+
+ if (game.lastMousePosition.x < 0) game.lastMousePosition.x = 0;
+ else if (game.lastMousePosition.x > width) game.lastMousePosition.x = width;
+
+ if (game.lastMousePosition.y < 0) game.lastMousePosition.y = 0;
+ else if (game.lastMousePosition.y > height) game.lastMousePosition.y = height;
+ }
+});
+
+document.addEventListener('touchstart', function (e) {
+ lastTouchStart = new Date().getTime();
+});
+document.addEventListener('touchend', function (e) {
+ if (
+ lastTouchStart !== undefined &&
+ new Date().getTime() - lastTouchStart < 300
+ ) {
+ handleClick(e);
+ }
+});
+
+window.requestFrame =
+ window.requestAnimationFrame ||
+ window.webkitRequestAnimationFrame ||
+ window.mozRequestAnimationFrame ||
+ function (callback) {
+ window.setTimeout(callback, 1000 / 60);
+ };
+
+var pauseDelay = 0;
+function animate() {
+ requestFrame(animate);
+ if (!paused) {
+ game.step();
+ if (pauseDelay !== 0) {
+ pauseDelay = 0;
+ if (canvas.requestPointerLock) canvas.requestPointerLock();
+ game.player.teleportTo({
+ x: game.lastMousePosition.x + 50,
+ y: game.lastMousePosition.y + 50,
+ });
+ }
+ } else if (paused && pauseDelay !== 1) {
+ game.step();
+ game.drawPauseMessage();
+ if (document.pointerLockElement) document.exitPointerLock();
+ pauseDelay++;
+ }
+}
+
+var scrollTimeout;
+window.addEventListener('scroll', function (e) {
+ clearTimeout(scrollTimeout);
+ scrollTimeout = setTimeout(function () {
+ window.scrollTo(0, 0);
+ }, 500);
+});
+window.scrollTo(0, 0);
+
+animate();
\ No newline at end of file
diff --git a/views/archive/g/tether/source/index.html b/views/archive/g/tether/source/index.html
new file mode 100644
index 00000000..a1255dae
--- /dev/null
+++ b/views/archive/g/tether/source/index.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+ tether!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hey there, this game needs Javascript. Turn it on to experience the excitement!
+
+
+
+
+
+
diff --git a/views/archive/g/tether/source/templates/main.template.html b/views/archive/g/tether/source/templates/main.template.html
new file mode 100644
index 00000000..086273e2
--- /dev/null
+++ b/views/archive/g/tether/source/templates/main.template.html
@@ -0,0 +1 @@
+tether! Hey there, this game needs Javascript. Turn it on to experience the excitement!
\ No newline at end of file
diff --git a/views/archive/g/tether/source/templates/offline.template.html b/views/archive/g/tether/source/templates/offline.template.html
new file mode 100644
index 00000000..27cfc521
--- /dev/null
+++ b/views/archive/g/tether/source/templates/offline.template.html
@@ -0,0 +1 @@
+tether! Hey there, this game needs Javascript. Turn it on to experience the excitement!
\ No newline at end of file
diff --git a/views/archive/g/tether/spashscreens/ipad_splash.png b/views/archive/g/tether/spashscreens/ipad_splash.png
new file mode 100644
index 00000000..f7e0086c
Binary files /dev/null and b/views/archive/g/tether/spashscreens/ipad_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/ipadpro1_splash.png b/views/archive/g/tether/spashscreens/ipadpro1_splash.png
new file mode 100644
index 00000000..b00a641a
Binary files /dev/null and b/views/archive/g/tether/spashscreens/ipadpro1_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/ipadpro2_splash.png b/views/archive/g/tether/spashscreens/ipadpro2_splash.png
new file mode 100644
index 00000000..ca3092f5
Binary files /dev/null and b/views/archive/g/tether/spashscreens/ipadpro2_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/ipadpro3_splash.png b/views/archive/g/tether/spashscreens/ipadpro3_splash.png
new file mode 100644
index 00000000..1008356f
Binary files /dev/null and b/views/archive/g/tether/spashscreens/ipadpro3_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphone5_splash.png b/views/archive/g/tether/spashscreens/iphone5_splash.png
new file mode 100644
index 00000000..ea436f46
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphone5_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphone6_splash.png b/views/archive/g/tether/spashscreens/iphone6_splash.png
new file mode 100644
index 00000000..85d9d071
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphone6_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphoneplus_splash.png b/views/archive/g/tether/spashscreens/iphoneplus_splash.png
new file mode 100644
index 00000000..3c29a955
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphoneplus_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphonex_splash.png b/views/archive/g/tether/spashscreens/iphonex_splash.png
new file mode 100644
index 00000000..7d243587
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphonex_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphonexr_splash.png b/views/archive/g/tether/spashscreens/iphonexr_splash.png
new file mode 100644
index 00000000..e17d977b
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphonexr_splash.png differ
diff --git a/views/archive/g/tether/spashscreens/iphonexsmax_splash.png b/views/archive/g/tether/spashscreens/iphonexsmax_splash.png
new file mode 100644
index 00000000..233a6ba7
Binary files /dev/null and b/views/archive/g/tether/spashscreens/iphonexsmax_splash.png differ
diff --git a/views/archive/g/tether/tether.webmanifest b/views/archive/g/tether/tether.webmanifest
new file mode 100644
index 00000000..3ed31074
--- /dev/null
+++ b/views/archive/g/tether/tether.webmanifest
@@ -0,0 +1,52 @@
+{
+ "name": "tether! | Swing Around a Ball of Destruction!",
+ "short_name": "tether!",
+ "lang": "en-US",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#FFF",
+ "description": "A game about swinging a ball around and sheer destruction.",
+ "categories": [
+ "game",
+ "mobile",
+ "fun"
+ ],
+ "icons": [
+ {
+ "src": "/icons/android-icon-36x36.png",
+ "sizes": "36x36",
+ "type": "image/png",
+ "density": "0.75"
+ },
+ {
+ "src": "/icons/android-icon-48x48.png",
+ "sizes": "48x48",
+ "type": "image/png",
+ "density": "1.0"
+ },
+ {
+ "src": "/icons/android-icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "density": "1.5"
+ },
+ {
+ "src": "/icons/android-icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "density": "2.0"
+ },
+ {
+ "src": "/icons/android-icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "density": "3.0"
+ },
+ {
+ "src": "/icons/android-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "density": "4.0"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/views/archive/g/tether/tether_theme.mp3 b/views/archive/g/tether/tether_theme.mp3
new file mode 100644
index 00000000..9cc1b7b0
Binary files /dev/null and b/views/archive/g/tether/tether_theme.mp3 differ
diff --git a/views/assets/img/tether.png b/views/assets/img/tether.png
new file mode 100644
index 00000000..12373978
Binary files /dev/null and b/views/assets/img/tether.png differ
diff --git a/views/expr/h5-nav.js b/views/expr/h5-nav.js
index aa960194..c437e27c 100644
--- a/views/expr/h5-nav.js
+++ b/views/expr/h5-nav.js
@@ -6,6 +6,7 @@ var h5gms = {
"mc": "/archive/g/mcjs.html",
"sna": "/archive/g/snake/index.html",
"retrobowl": "/archive/g/retrobowl/index.html",
+ "tether": "/archive/g/tether/index.html",
"retro": "/archive/g/retrohaunt/index.html",
"cookiec": "/archive/g/cookieclicker/index.html",
"evilg": "/archive/g/evilglitch/index.html",
diff --git a/views/pages/nav/games5.html b/views/pages/nav/games5.html
index 240945c6..d9cb740c 100644
--- a/views/pages/nav/games5.html
+++ b/views/pages/nav/games5.html
@@ -114,6 +114,16 @@
+
+
+
+
+
+
+
tether!
tether! is game where you wreck as many enemies as possible using your tether, however if an enemy touches your ball then you get obliterated!
+
+
+