fuzzyworld

dao-game / commit

Merge branch 'multiplayer-scratch' into 'main'

53afb0451701fdc5f14c500dd30e8e4975f7c259 · Pavel S · 2022-03-22 01:30:41 +0000 · parents 81976d87cdb3 63c9d8378a7c

Merge branch 'multiplayer-scratch' into 'main'

Add multiplayer support

See merge request fuzzy-rp/dao-game!4
 .gitignore                       |   2 +-
 .gitlab-ci.yml                   |   6 +-
 .prettierrc                      |   8 +
 .vscode/launch.json              |  20 +-
 .vscode/settings.json            |   3 +
 package-lock.json                | 109 ++++++++-
 package.json                     |   3 +-
 public/cursor.png                | Bin 0 -> 504 bytes
 public/index.html                | 142 ++++--------
 public/local.html                |  70 ++++++
 public/remote.html               |  72 ++++++
 public/share.png                 | Bin 0 -> 322 bytes
 public/styles.css                | 490 +++++++++++++++++++++------------------
 readme.md                        |  15 +-
 src/game-board.ts                |  67 ++++++
 src/game.ts                      | 475 +++++++++++++------------------------
 src/listeners.ts                 |  24 ++
 src/local-game.ts                | 264 +++++++++++++++++++++
 src/local.ts                     |   3 +
 src/modal.ts                     |  59 +++++
 src/online.ts                    |   3 +
 src/remote-game.ts               | 212 +++++++++++++++++
 src/storage.ts                   |  27 +++
 src/types.ts                     | 141 ++++-------
 src/utils.ts                     |  32 +++
 tools/esbuild-build-and-serve.ts |  68 +++---
 tools/esbuild-settings.ts        |  13 ++
 tools/esbuild-watch.ts           |   8 +-
 tools/esbuild.ts                 |   7 +-
 29 files changed, 1561 insertions(+), 782 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9b60cca..6760a00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,4 @@
 /.git/*
 
 # artifacts
-/public/bundle.js
\ No newline at end of file
+/public/js
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 590999b..4e85ef4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -4,8 +4,10 @@ pages:
   script:
     - npm install
     - npm run build
+    - mv public .public
+    - mkdir -p public
+    - mkdir -p public/$CI_COMMIT_BRANCH
+    - cp .public/* public/$CI_COMMIT_BRANCH/ -R
   artifacts:
     paths:
       - public
-  only:
-    - main
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..8350497
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,8 @@
+{
+  "tabWidth": 2,
+  "useTabs": false,
+  "semi": true,
+  "trailingComma": "es5",
+  "singleQuote": true,
+  "printWidth": 140
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index b9fd332..98ef16e 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -7,10 +7,26 @@
         {
             "type": "pwa-chrome",
             "request": "launch",
-            "name": "Launch Chrome against localhost",
+            "name": "Debug Landing Page",
             "url": "http://localhost:3000/",
             "webRoot": "${workspaceFolder}/src",
-            "preLaunchTask": "task: npm: start"
+            // "preLaunchTask": "task: npm: start"
+        },
+        {
+            "type": "pwa-chrome",
+            "request": "launch",
+            "name": "Debug Local Game",
+            "url": "http://localhost:3000/local.html",
+            "webRoot": "${workspaceFolder}/src",
+            // "preLaunchTask": "task: npm: start"
+        },
+        {
+            "type": "pwa-chrome",
+            "request": "launch",
+            "name": "Debug Remote Game",
+            "url": "http://localhost:3000/remote.html",
+            "webRoot": "${workspaceFolder}/src",
+            // "preLaunchTask": "task: npm: start"
         }
     ]
 }
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 1e2d4fa..48c5f7c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,10 @@
 {
     "cSpell.words": [
         "esbuild",
+        "Newp",
+        "outdir",
         "outfile",
+        "peerjs",
         "servedir",
         "Todos"
     ]
diff --git a/package-lock.json b/package-lock.json
index bb7d6c6..59eac04 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
       "version": "1.0.0",
       "license": "ISC",
       "dependencies": {
-        "esbuild": "^0.14.25"
+        "esbuild": "^0.14.25",
+        "peerjs": "^1.3.2"
       },
       "devDependencies": {
         "@types/node": "^17.0.21",
@@ -467,6 +468,11 @@
         "node": ">=12"
       }
     },
+    "node_modules/eventemitter3": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
+      "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -545,6 +551,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/peerjs": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz",
+      "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==",
+      "dependencies": {
+        "@types/node": "^10.14.33",
+        "eventemitter3": "^3.1.2",
+        "peerjs-js-binarypack": "1.0.1",
+        "webrtc-adapter": "^7.7.1"
+      }
+    },
+    "node_modules/peerjs-js-binarypack": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz",
+      "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg=="
+    },
+    "node_modules/peerjs/node_modules/@types/node": {
+      "version": "10.17.60",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+      "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="
+    },
     "node_modules/rimraf": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -560,6 +587,23 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/rtcpeerconnection-shim": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz",
+      "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==",
+      "dependencies": {
+        "sdp": "^2.6.0"
+      },
+      "engines": {
+        "node": ">=6.0.0",
+        "npm": ">=3.10.0"
+      }
+    },
+    "node_modules/sdp": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
+      "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
+    },
     "node_modules/ts-node": {
       "version": "10.6.0",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.6.0.tgz",
@@ -621,6 +665,19 @@
       "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
       "dev": true
     },
+    "node_modules/webrtc-adapter": {
+      "version": "7.7.1",
+      "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz",
+      "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==",
+      "dependencies": {
+        "rtcpeerconnection-shim": "^1.2.15",
+        "sdp": "^2.12.0"
+      },
+      "engines": {
+        "node": ">=6.0.0",
+        "npm": ">=3.10.0"
+      }
+    },
     "node_modules/wrappy": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -882,6 +939,11 @@
       "integrity": "sha512-4ype9ERiI45rSh+R8qUoBtaj6kJvUOI7oVLhKqPEpcF4Pa5PpT3hm/mXAyotJHREkHpM87PAJcA442mLnbtlNA==",
       "optional": true
     },
+    "eventemitter3": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
+      "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -948,6 +1010,29 @@
       "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
       "dev": true
     },
+    "peerjs": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/peerjs/-/peerjs-1.3.2.tgz",
+      "integrity": "sha512-+PHfmsC7QGUU8Ye3OLi6tKQZGPCNy7QatUVNw4JtE8alkguF3+DdO5W0bzepqP2OtE9FqH/ltXt37qyvHw2CqA==",
+      "requires": {
+        "@types/node": "^10.14.33",
+        "eventemitter3": "^3.1.2",
+        "peerjs-js-binarypack": "1.0.1",
+        "webrtc-adapter": "^7.7.1"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "10.17.60",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+          "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="
+        }
+      }
+    },
+    "peerjs-js-binarypack": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/peerjs-js-binarypack/-/peerjs-js-binarypack-1.0.1.tgz",
+      "integrity": "sha512-N6aeia3NhdpV7kiGxJV5xQiZZCVEEVjRz2T2C6UZQiBkHWHzUv/oWA4myQLcwBwO8LUoR1KWW5oStvwVesmfCg=="
+    },
     "rimraf": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -957,6 +1042,19 @@
         "glob": "^7.1.3"
       }
     },
+    "rtcpeerconnection-shim": {
+      "version": "1.2.15",
+      "resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz",
+      "integrity": "sha512-C6DxhXt7bssQ1nHb154lqeL0SXz5Dx4RczXZu2Aa/L1NJFnEVDxFwCBo3fqtuljhHIGceg5JKBV4XJ0gW5JKyw==",
+      "requires": {
+        "sdp": "^2.6.0"
+      }
+    },
+    "sdp": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
+      "integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
+    },
     "ts-node": {
       "version": "10.6.0",
       "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.6.0.tgz",
@@ -990,6 +1088,15 @@
       "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
       "dev": true
     },
+    "webrtc-adapter": {
+      "version": "7.7.1",
+      "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-7.7.1.tgz",
+      "integrity": "sha512-TbrbBmiQBL9n0/5bvDdORc6ZfRY/Z7JnEj+EYOD1ghseZdpJ+nF2yx14k3LgQKc7JZnG7HAcL+zHnY25So9d7A==",
+      "requires": {
+        "rtcpeerconnection-shim": "^1.2.15",
+        "sdp": "^2.12.0"
+      }
+    },
     "wrappy": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
diff --git a/package.json b/package.json
index 7569d29..e9cc180 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,8 @@
   },
   "homepage": "https://gitlab.com/fuzzy-rp/dao-game#readme",
   "dependencies": {
-    "esbuild": "^0.14.25"
+    "esbuild": "^0.14.25",
+    "peerjs": "^1.3.2"
   },
   "devDependencies": {
     "@types/node": "^17.0.21",
diff --git a/public/cursor.png b/public/cursor.png
new file mode 100644
index 0000000..b34724b
Binary files /dev/null and b/public/cursor.png differ
diff --git a/public/index.html b/public/index.html
index 033ea5a..defd512 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,95 +1,53 @@
 <!DOCTYPE html>
 <html lang="en">
 
-    <head>
-        <meta charset="utf-8">
-        <title>Dao Game</title>
-        <meta name="viewport"
-              content="width=device-width, initial-scale=1">
-        <link rel="icon"
-              href="yin-yang.svg"
-              type="image/svg+xml">
-        <link rel="stylesheet"
-              href="styles.css" />
-        <script defer
-                src="bundle.js"></script>
-    </head>
-
-    <body>
-
-        <div class="invisible">
-            <p class='piece red-piece'
-               id='default-red-piece'></p>
-            <p class='piece blue-piece'
-               id='default-blue-piece'></p>
-        </div>
-        <main>
-
-            <div class="controls">
-
-                <span class="red-turn-text">
-                    Red's turn!
-                </span>
-
-                <span class="divider">|</span>
-
-                <span class="blue-turn-text">
-                    Blue's turn
-                </span>
-            </div>
-
-            <table id="table">
-            </table>
-
-            <div class="controls">
-                <button type="button"
-                        class="button"
-                        id="undo-button">
-                    Undo
-                </button>
-
-                <span class="divider">|</span>
-                <button type="button"
-                        class="button"
-                        id="reset-button">
-                    Reset
-                </button>
-            </div>
-        </main>
-        <hr />
-
-        <div class="title">
-            <p>
-                Board game <a href='https://en.wikipedia.org/wiki/Dao_(game)'
-                   target='_blank'
-                   rel="noopener">Dao</a>.
-            </p>
-        </div>
-
-        <div class="info">
-            The game consists of only one simple rule and three ways
-            to
-            win. Pieces move in a straight line, including
-            diagonally,
-            as far as possible with no jumps. The three ways to win
-            are
-            arranging your pieces in a straight line on the board
-            (not
-            diagonally), occupying the four corners, or forming a
-            2x2
-            square anywhere on the board.
-        </div>
-        <hr />
-
-        <div class="info">
-            <p>
-                Here is the
-                <a href='https://gitlab.com/fuzzy-rp/dao-game'
-                   target="_blank"
-                   rel="noopener">source
-                    code</a>
-            </p>
-        </div>
-    </body>
-
-</html>
+<head>
+  <meta charset="utf-8" />
+  <title>Dao Game</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <link rel="icon" href="yin-yang.svg" type="image/svg+xml" />
+  <link rel="stylesheet" href="styles.css" />
+</head>
+
+<body>
+  <div class="title">
+    <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p>
+  </div>
+
+  <div class="info">
+    The game consists of only one simple rule and three ways to win.
+
+    Rules:
+    <ul>
+      <li>
+        Pieces move in a straight line, including diagonally, as far as possible with no jumps.
+      </li>
+    </ul>
+    Ways to win:
+    <ol>
+      <li>Arrange your pieces in a straight line on the board (not diagonally)</li>
+      <li>Occupy four corners of the board</li>
+      <li>Form a 2x2 square anywhere on the board</li>
+    </ol>
+  </div>
+  <hr />
+  <div class="info">
+    <div>
+      Play local (pass-and-play) game of Dao <a href='local.html'>here</a>.
+    </div>
+    <div>
+      Play online game of Dao <a href='remote.html'>here</a>.
+    </div>
+  </div>
+
+  <div class="info">
+    <p>
+      Here is the
+      <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a>
+    </p>
+  </div>
+
+  <div id="snackbar" class="info"></div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/public/local.html b/public/local.html
new file mode 100644
index 0000000..c3fc3cf
--- /dev/null
+++ b/public/local.html
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8" />
+    <title>Dao Game</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="icon" href="yin-yang.svg" type="image/svg+xml" />
+    <link rel="stylesheet" href="styles.css" />
+    <script defer src="js/local.js"></script>
+</head>
+
+<body>
+    <div id="placeholders" class="invisible">
+        <p class="piece p1-piece" id="default-p1-piece"></p>
+        <p class="piece p2-piece" id="default-p2-piece"></p>
+    </div>
+    <main>
+        <div class="controls">
+            <span class="p1-turn-text">Red</span>
+            <span class="p2-turn-text">Blue</span>
+        </div>
+
+        <table id="table"></table>
+
+        <div class="controls">
+            <button type="button" class="button" id="undo-button">
+                Undo
+            </button>
+            <button type="button" class="button" id="reset-button">
+                Reset
+            </button>
+        </div>
+    </main>
+
+    <div class="title">
+        <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p>
+    </div>
+
+    <div class="info">
+        The game consists of only one simple rule and three ways to win.
+
+        Rules:
+        <ul>
+            <li>
+                Pieces move in a straight line, including diagonally, as far as possible with no jumps.
+            </li>
+        </ul>
+        Ways to win:
+        <ol>
+            <li>Arrange your pieces in a straight line on the board (not diagonally)</li>
+            <li>Occupy four corners of the board</li>
+            <li>Form a 2x2 square anywhere on the board</li>
+        </ol>
+    </div>
+
+    <hr />
+
+    <div class="info">
+        <p>
+            Here is the
+            <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a>
+        </p>
+    </div>
+
+    <div id="snackbar"></div>
+
+</body>
+
+</html>
\ No newline at end of file
diff --git a/public/remote.html b/public/remote.html
new file mode 100644
index 0000000..ff94ed7
--- /dev/null
+++ b/public/remote.html
@@ -0,0 +1,72 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="utf-8" />
+  <title>Dao Game</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <link rel="icon" href="yin-yang.svg" type="image/svg+xml" />
+  <link rel="stylesheet" href="styles.css" />
+  <script defer src="js/online.js"></script>
+</head>
+
+<body>
+  <div id="placeholders" class="invisible">
+    <p class="piece p1-piece" id="default-p1-piece"></p>
+    <p class="piece p2-piece" id="default-p2-piece"></p>
+  </div>
+  <main class="invisible">
+    <div class="controls">
+      <span class="p1-turn-text">Red</span>
+      <span class="p2-turn-text">Blue</span>
+    </div>
+
+    <table id="table"></table>
+  </main>
+
+  <hr />
+  <div id="share-container" class="invisible info">
+    Press this button to share a link with your opponent.
+    <button id="share-link">Share <img src="share.png" /></button>
+  </div>
+  <hr />
+
+  <div class="title">
+    <p>Board game <a href="https://en.wikipedia.org/wiki/Dao_(game)" target="_blank" rel="noopener">Dao</a>.</p>
+  </div>
+
+  <div class="info">
+    The game consists of only one simple rule and three ways to win.
+
+    Rules:
+    <ul>
+      <li>
+        Pieces move in a straight line, including diagonally, as far as possible with no jumps.
+      </li>
+    </ul>
+    Ways to win:
+    <ol>
+      <li>Arrange your pieces in a straight line on the board (not diagonally)</li>
+      <li>Occupy four corners of the board</li>
+      <li>Form a 2x2 square anywhere on the board</li>
+    </ol>
+  </div>
+
+  <hr />
+
+  <div class="info">
+    <p>
+      Here is the
+      <a href="https://gitlab.com/fuzzy-rp/dao-game" target="_blank" rel="noopener">source code</a>
+    </p>
+  </div>
+
+  <div id="snackbar" class="info"></div>
+
+  <div id="other-mouse">
+    <img src="./cursor.png" alt="cursor" />
+  </div>
+
+</body>
+
+</html>
\ No newline at end of file
diff --git a/public/share.png b/public/share.png
new file mode 100644
index 0000000..921f933
Binary files /dev/null and b/public/share.png differ
diff --git a/public/styles.css b/public/styles.css
index cb59d42..79f8c94 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -1,294 +1,336 @@
 :root {
-    --default-size: 5px;
-    --active-size: 40px;
-    --cell-size: 80px;
-    /* --p1-color: crimson;
-    --p2-color: mediumblue; */
+  --default-size: 5px;
+  --active-size: 40px;
+  --active-pulse-size: 30px;
+  --cell-size: 80px;
+  --max-width: 500px;
+  --font-family: Arial, sans-serif;
 }
 
-* {
-    margin: auto;
+#other-mouse {
+  position: absolute;
+  z-index: 1000;
+  pointer-events: none;
+  display: none;
 }
 
-main {
-    display: flex;
-    flex-direction: column;
-    justify-content: space-between;
-    align-items: center;
-    /* height: 75vh; */
+#table {
+  margin: auto;
 }
 
-body {
-    /* overflow: hidden; */
-    margin: 0px;
-    background-color: white;
+#undo-button {
+  float: left;
+}
+#reset-button {
+  float: right;
 }
 
-div {
-    display: flex;
-    justify-content: space-between;
-    /* display: inline;
-    flex: 1; */
+#share-container {
+  align-content: center;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+}
+
+#share-link {
+  background-color: rgb(106, 170, 100);
+  border: black 1px solid;
+  max-width: var(--max-width);
+  margin: auto;
+  border-radius: 5px;
+  cursor: pointer;
+  display: flex;
+  
+  align-content: center;
+  align-items: center;
+  justify-content: center;
+  text-align: center;
+
+  text-transform: uppercase;
 }
 
 .invisible {
-    display: none;
+  display: none;
 }
 
 .title {
-    text-align: center;
-    font-family: "open sans";
-    font-size: 40px;
+  text-align: center;
+  font-family: var(--font-family);
+  font-size: 40px;
+  max-width: var(--max-width);
+  margin: auto;
 }
 
-/* tile */
-td {
-    width: var(--cell-size);
-    height: var(--cell-size);
-    text-align: center;
-    border: 2px transparent solid;
+.tile {
+  width: var(--cell-size);
+  height: var(--cell-size);
+  text-align: center;
+  border: 2px transparent solid;
 }
 
 .controls {
-    font-size: 90px;
-    margin-left: 15px;
-    margin-right: 15px;
-    font-family: "open sans";
-    font-size: 55px;
-    border-radius: 10px;
-    padding: 10px;
-}
-
-.divider {
-    font-size: 90px;
-    margin-left: 15px;
-    margin-right: 15px;
+  font-size: 90px;
+  font-family: var(--font-family);
+  font-size: 55px;
+  border-radius: 10px;
+  padding: 10px;
+  display: flex;
+  max-width: var(--max-width);
+  margin: auto;
 }
 
-.red-turn-text, .blue-turn-text {
-    text-align: center;
-    color: lightgray;
-    background-color: white;
-    /* border: 1px solid black; */
-    border-radius: 10px;
-    padding: 10px;
-    -ms-user-select: none;
-    -webkit-user-select: none;
-    user-select: none;
+.p1-turn-text,
+.p2-turn-text {
+  text-align: center;
+  color: lightgray;
+  background-color: white;
+  border-radius: 10px;
+  padding: 10px;
+  -ms-user-select: none;
+  -webkit-user-select: none;
+  user-select: none;
+  margin: auto;
 }
 
-.red-turn-text.selected-player-text {
-    background-color: red;
+.p1-turn-text.selected-player-text {
+  background-color: red;
 }
 
-.blue-turn-text.selected-player-text {
-    background-color: blue;
+.p2-turn-text.selected-player-text {
+  background-color: blue;
 }
 
 .selected-player-text {
-    color: white;
+  color: white;
 }
 
 .button {
-    border-radius: 10px;
-    padding: 10px;
-    background-color: orange;
-    font-size: 60px;
-    border-color: transparent;
-    transition: .2s all;
+  border-radius: 10px;
+  padding: 10px;
+  background-color: orange;
+  font-size: 60px;
+  border-color: transparent;
+  transition: 0.2s all;
+  margin: auto;
 }
 .button:active {
-    transform: scale(0.98);
-    /* Scaling button to 0.98 to its original size */
-    box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24);
-    /* Lowering the shadow */
+  transform: scale(0.98);
+  box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24);
 }
 
 .white {
-    background-color: orange;
+  background-color: orange;
 }
 .black {
-    background-color: wheat;
+  background-color: wheat;
 }
 
 .piece {
-    width: var(--default-size);
-    height: var(--default-size);
-    padding: 10px;
-    border-radius: 50px;
-    animation: activeToDisabled 1s 1;    
-    /* background-image: url(yin-yang.svg); */
+  width: var(--default-size);
+  height: var(--default-size);
+  padding: 10px;
+  border-radius: 50px;
+  animation: activeToDisabled .5s 1;
+  margin: auto;
 }
 
-.red-piece {
-    background-color: crimson;
-    /* background-image: url(yin-yang.svg); */
+.p1-piece {
+  background-color: crimson;
 }
-.blue-piece {
-    background-color: mediumblue;
-    /* background-image: url(yin-yang.svg); */
+.p2-piece {
+  background-color: mediumblue;
 }
 
 .active-player-piece {
-    width: var(--active-size);
-    height: var(--active-size);
-    animation: disabledToActive 1s 1;
+  width: var(--active-size);
+  height: var(--active-size);
+  animation: disabledToActive .5s 1;
 }
 
 .selected-piece {
-    animation: activePulsing 1s infinite;
+  animation: activePulsing .5s infinite;
 }
 
 .info {
-    text-align: center;
-    font-family: "open sans";
-    font-size: 20px;
+  /* text-align: center; */
+  font-family: var(--font-family);
+  font-size: 20px;
+  max-width: var(--max-width);
+  margin: auto;
 }
 
 .possible-move {
-    animation: blinkingText 1s infinite;
-    border-color: black;
-    filter: drop-shadow(0px 0px 5px black);
+  animation: blinkingText 1s infinite;
+  border-color: black;
+  filter: drop-shadow(0px 0px 5px black);
 }
 @keyframes blinkingText {
-    0%{opacity: 1;}
-    50%{opacity: .6;}
-    100%{opacity: 1;}
+  0% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.6;
+  }
+  100% {
+    opacity: 1;
+  }
 }
 
 @keyframes fade-in {
-    0%{opacity: 0;}
-    50%{opacity: .5;}
-    100%{opacity: 1;}    
+  0% {
+    opacity: 0;
+  }
+  50% {
+    opacity: 0.5;
+  }
+  100% {
+    opacity: 1;
+  }
 }
 
 @keyframes disabledToActive {
-    from {
-        width: var(--default-size);
-        height: var(--default-size);
-    }
-    to {
-        width: var(--active-size);
-        height: var(--active-size);
-    }
+  from {
+    width: var(--default-size);
+    height: var(--default-size);
+  }
+  to {
+    width: var(--active-size);
+    height: var(--active-size);
+  }
 }
 @keyframes activeToDisabled {
-    from {
-        width: var(--active-size);
-        height: var(--active-size);
-    }
-    to {
-        width: var(--default-size);
-        height: var(--default-size);
-    }
+  from {
+    width: var(--active-size);
+    height: var(--active-size);
+  }
+  to {
+    width: var(--default-size);
+    height: var(--default-size);
+  }
 }
 
-
-/* .pulsing {
-    animation: pulsingSize 1.5s infinite;
-} */
 @keyframes activePulsing {
-    from {
-        width: var(--active-size);
-        height: var(--active-size);
-    }
-    50% {
-        width: 25px;
-        height: 25px;
-    }
-    to {
-        width: var(--active-size);
-        height: var(--active-size);
-    }
-}
-
-/* breaks */
-
-/* @media screen and (max-width: 560px) {
-    .red-piece {
-        height: 4.5vw;
-        width: 4.5vw;
-    }
-
-    .black-piece {
-        height: 4.5vw;
-        width: 4.5vw;
-    }
-
-    td {
-        width: 12vw;
-        height: 12vw;
-    }
-
-    .red-turn-text {
-        font-size: 40px;
-    }
-    
-    .black-turn-text {
-        font-size: 40px;
-    }
-
-    table {
-        margin: 0;
-    }
-
-    .mobile {
-        display: block;
-    }
-
-    .desktop {
-        display: none;
-    }
-
-    .red-turn-text {
-        -webkit-transform:rotate(-180deg);
-        -moz-transform:rotate(-180deg);
-        -o-transform:rotate(-180deg);
-        transform:rotate(-180deg);
-        filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);    
-    }
-}
-
-@media screen and (max-width: 380px) {
-    .red-piece {
-        height: 3vw;
-        width: 3vw;
-    }
-
-    .black-piece {
-        height: 3vw;
-        width: 3vw;
-    }
-}
-
-@media screen and (max-width: 345px) {
-    .red-piece {
-        height: 2.5vw;
-        width: 2.5vw;
-    }
-
-    .black-piece {
-        height: 2.5vw;
-        width: 2.5vw;
-    }
-}
-
-@media screen and (max-width: 300px) {
-    .red-piece {
-        height: 1.5vw;
-        width: 1.5vw;
-    }
-
-    .black-piece {
-        height: 1.5vw;
-        width: 1.5vw;
-    }
-
-    .red-turn-text {
-        font-size: 35px;
-    }
-    
-    .black-turn-text {
-        font-size: 35px;
-    }
-} */
\ No newline at end of file
+  from {
+    opacity: 1;
+  }
+  50% {
+    opacity: .5;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+/* Snackbar, from https://www.w3schools.com/howto/howto_js_snackbar.asp */
+
+/* The snackbar - position it at the bottom and in the middle of the screen */
+#snackbar {
+  visibility: hidden; /* Hidden by default. Visible on click */
+  min-width: 250px; /* Set a default minimum width */
+  margin-left: -125px; /* Divide value of min-width by 2 */
+  background-color: #333; /* Black background color */
+  color: #fff; /* White text color */
+  text-align: center; /* Centered text */
+  border-radius: 2px; /* Rounded borders */
+  padding: 16px; /* Padding */
+  position: fixed; /* Sit on top of the screen */
+  z-index: 1; /* Add a z-index if needed */
+  left: 50%; /* Center the snackbar */
+  bottom: 30px; /* 30px from the bottom */
+}
+
+/* Show the snackbar when clicking on a button (class added with JavaScript) */
+#snackbar.show {
+  visibility: visible; /* Show the snackbar */
+  /* Add animation: Take 0.5 seconds to fade in and out the snackbar.
+  However, delay the fade out process for 2.5 seconds */
+  -webkit-animation: fadein 0.5s, fadeout 0.5s 2.5s;
+  animation: fadein 0.5s, fadeout 0.5s 2.5s;
+}
+
+/* Animations to fade the snackbar in and out */
+@-webkit-keyframes fadein {
+  from {
+    bottom: 0;
+    opacity: 0;
+  }
+  to {
+    bottom: 30px;
+    opacity: 1;
+  }
+}
+
+@keyframes fadein {
+  from {
+    bottom: 0;
+    opacity: 0;
+  }
+  to {
+    bottom: 30px;
+    opacity: 1;
+  }
+}
+
+@-webkit-keyframes fadeout {
+  from {
+    bottom: 30px;
+    opacity: 1;
+  }
+  to {
+    bottom: 0;
+    opacity: 0;
+  }
+}
+
+@keyframes fadeout {
+  from {
+    bottom: 30px;
+    opacity: 1;
+  }
+  to {
+    bottom: 0;
+    opacity: 0;
+  }
+}
+
+/* Modal from https://www.w3schools.com/howto/howto_css_modals.asp */
+ /* The Modal (background) */
+.modal {
+  display: none; /* Hidden by default */
+  position: fixed; /* Stay in place */
+  z-index: 1; /* Sit on top */
+  left: 0;
+  top: 0;
+  width: 100%; /* Full width */
+  height: 100%; /* Full height */
+  overflow: auto; /* Enable scroll if needed */
+  background-color: rgb(0,0,0); /* Fallback color */
+  background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
+  font-family: var(--font-family);
+  font-size: 30px;
+}
+
+/* Modal Content/Box */
+.modal-content {
+  background-color: #fefefe;
+  margin: 100px auto; /* 15% from the top and centered */
+  padding: 20px;
+  border: 1px solid #888;
+  /* width: 80%; /* Could be more or less, depending on screen size */
+  max-width: var(--max-width);
+}
+
+/* The Close Button */
+.close {
+  color: #aaa;
+  float: right;
+  font-size: 28px;
+  font-weight: bold;
+}
+
+.close:hover,
+.close:focus {
+  color: black;
+  text-decoration: none;
+  cursor: pointer;
+} 
\ No newline at end of file
diff --git a/readme.md b/readme.md
index a7939e5..2e86fd3 100644
--- a/readme.md
+++ b/readme.md
@@ -3,10 +3,16 @@
 ## Running
 
 1. `npm run start`
+2. Navigate to [`http://localhost:3000/`](http://localhost:3000/)
+
+## Debugging
+
+1. `npm run start`
+2. Run `Launch Chrome against localhost` debug task in VSCode
 
 ## Gitlab Pages
 
-[Location](https://fuzzy-rp.gitlab.io/dao-game/)
+[https://fuzzy-rp.gitlab.io/dao-game/multiplayer-scratch/](https://fuzzy-rp.gitlab.io/dao-game/multiplayer-scratch/)
 
 ## Esbuild
 
@@ -14,6 +20,7 @@ This project is using [esbuild](https://esbuild.github.io/getting-started/#build
 
 ## Todos
 
-1. Use esbuild and rewrite app in TypeScript
-2. Online multiplayer
-  - Follow the deployment logic [here](https://gitlab.com/fuzzy-rp/flight3d/-/blob/master/.gitlab-ci.yml) to deploy a signaling service to dreamhost and use that for this game.
+1. Online multiplayer
+2. Figure out way to have different pages access different JS
+3. There's a bug where opening a new tab results in the same session data, look into this and figure out why and how to prevent this
+4. Publish versions of all branches
diff --git a/src/game-board.ts b/src/game-board.ts
new file mode 100644
index 0000000..f9f54e9
--- /dev/null
+++ b/src/game-board.ts
@@ -0,0 +1,67 @@
+import { Coord } from "./types";
+
+
+export class GameBoard {
+  private board: (string | null)[];
+  public readonly size: Coord;
+  public readonly player1Id: string;
+  public readonly player2Id: string;
+
+  public constructor(player1Id: string, player2Id: string) {
+    // default board
+    const defaultBoard = []
+      .concat([player1Id, null, null, player2Id])
+      .concat([null, player1Id, player2Id, null])
+      .concat([null, player2Id, player1Id, null])
+      .concat([player2Id, null, null, player1Id]);
+    const testBoard = []
+      .concat([player1Id, null, player2Id, player2Id])
+      .concat([player1Id, null, player2Id, null])
+      .concat([player1Id, null, null, null])
+      .concat([null, player1Id, null, player2Id]);
+    this.board = defaultBoard;
+    this.size = new Coord(4, 4);
+    this.player1Id = player1Id;
+    this.player2Id = player2Id;
+  }
+  public move(source: Coord, dest: Coord): void {
+    const isP1 = this.get(source);
+    if (isP1 === null)
+      throw `Source ${source} points to an empty space`;
+    this.set(source, null);
+    this.set(dest, isP1);
+  }
+  public get(coord: Coord): boolean | null {
+    const index = this.coordToBoardIndex(coord);
+    if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length)
+      throw `Index ${index} is out of bounds`;
+    const id = this.board[index];
+    const isP1 = id === null ? null : id === this.player1Id;
+    return isP1;
+  }
+  public getCoords(id: string): Coord[] {
+    const coords = this.board
+      .map((pieceId, index) => {
+        const isTarget = pieceId === id;
+        return isTarget ? this.boardIndexToCoord(index) : null;
+      })
+      .filter((coord) => coord !== null);
+    return coords;
+  }
+  private set(coord: Coord, isP1: boolean | null): void {
+    const index = this.coordToBoardIndex(coord);
+    if (!Number.isSafeInteger(index) || index < 0 || index >= this.board.length)
+      throw `Index ${index} is out of bounds`;
+    this.board[index] = isP1 === null ? null : isP1 ? this.player1Id : this.player2Id;
+  }
+  public coordToBoardIndex(coord: Coord): number {
+    const { row, col } = coord;
+    const index = row * this.size.col + col;
+    return index;
+  }
+  private boardIndexToCoord(index: number): Coord {
+    const col = index % this.size.col;
+    const row = Math.floor(index / this.size.col);
+    return new Coord(row, col);
+  }
+}
diff --git a/src/game.ts b/src/game.ts
index 60fa73c..2cfd4ee 100644
--- a/src/game.ts
+++ b/src/game.ts
@@ -1,333 +1,178 @@
-import { Coord, GameBoard, ListenerManager, Move } from './types'
-
-const constants = {
-    selectedPieceClassName: 'selected-piece',
-    selectedPlayerTextClassName: 'selected-player-text',
-    activePlayerPieceClassName: 'active-player-piece',
-    possibleMoveClassName: 'possible-move',
+import { Coord, Move, MoveResult } from './types';
+import { GameBoard } from "./game-board";
+
+export interface IGame {
+  size: Coord;
+  moveHistory: Move[];
+  player1Id: string;
+  player1Name: string;
+  player2Id: string;
+  player2Name: string;
+  currentPlayerId: string;
+  canUndoMove: boolean;
+  getPlayerPositions: (playerId: string) => Coord[];
+  coordToBoardIndex: (coord: Coord) => number;
+  getValidMoves: (start: Coord) => Coord[];
+  makeMove: (source: Coord, dest: Coord) => Promise<MoveResult>;
+  undoMove: () => Move;
+  getPiece(pos: Coord): string | undefined;
 }
 
-const gameBoard = new GameBoard();
-const listenerManager: ListenerManager = new ListenerManager();
-
-const player: { isRedTurn: boolean, selectedPieceId: number | undefined, moveHistory: Move[] } = {
-    isRedTurn: false,
-    selectedPieceId: undefined,
-    moveHistory: [],
-}
-
-// DOM references
-const dom = {
-    defaultRedPiece: document.getElementById('default-red-piece'),
-    defaultBluePiece: document.getElementById('default-blue-piece'),
-    cells: () => Array.from(document.querySelectorAll("td")),
-    allPieces: () => Array.from(document.querySelectorAll('.piece')),
-    playerPieces: (isRedTurn: boolean) => isRedTurn ?
-        Array.from(document.querySelectorAll('.red-piece:not(#default-red-piece')) :
-        Array.from(document.querySelectorAll('.blue-piece:not(#default-blue-piece')),
-    redTurnText: () => Array.from(document.querySelectorAll(".red-turn-text")),
-    blueTurnText: () => Array.from(document.querySelectorAll(".blue-turn-text")),
-    resetButton: document.getElementById('reset-button') as HTMLInputElement,
-    undoButton: document.getElementById('undo-button') as HTMLInputElement,
-}
-
-const resetBoard = () => {
-    // resetCells
-    dom.cells().forEach(cell => {
-        cell.classList.remove(constants.possibleMoveClassName);
-    });
-
-    // reset pieces
-    dom.allPieces().forEach(piece => {
-        if (piece.classList.contains(constants.activePlayerPieceClassName)) {
-            piece.classList.toggle(constants.activePlayerPieceClassName);
-        }
-        if (piece.classList.contains(constants.selectedPieceClassName)) {
-            piece.classList.toggle(constants.selectedPieceClassName);
-        }
-    });
-
-    // remove all listeners
-    listenerManager.removeListeners();
-};
-
-const movePiece = async (sourceIndex: number, destIndex: number) => {
-    const pieceId = gameBoard.board[sourceIndex];
-    // find current piece
-    const piece = document.getElementById(pieceId.toString());
-    // unselect piece
-    piece.classList.remove(constants.selectedPieceClassName);
-
-    // create clone for new position
-    const destPiece = piece.cloneNode(true);
-    // add to destination cell
-    dom.cells()[destIndex].appendChild(destPiece);
-
-    // start animations
-    const destAnimation = dom.cells()[destIndex].animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1000, iterations: 1 })
-    const sourceAnimation = piece.animate([{
-        opacity: 1,
-        width: '45px',
-        height: '45px'
-    }, {
-        opacity: 0,
-        width: '5px',
-        height: '5px'
-    },], { duration: 1000, iterations: 1 });
-
-    // await animations
-    await Promise.all([sourceAnimation.finished, destAnimation.finished]);
-
-    // remove old piece
-    piece.parentNode.removeChild(piece);
-    gameBoard.board[sourceIndex] = null;
-    // set new piece
-    gameBoard.board[destIndex] = pieceId;
-};
-
-const nextMove = () => {
-    const setActivePlayerPieces = () => {
-        const selectPiece = (event: Event) => {
-            const getAllowedMoveIndicesForIndex = (boardIndex: number): number[] => {
-                const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => {
-                    let farthestPos = undefined;
-                    let testPos = startCoord;
-                    while (true) {
-                        const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col);
-                        const index = gameBoard.coordToBoardIndex(pos);
-                        const withinBounds =
-                            pos.row >= 0 && pos.row < gameBoard.rows &&
-                            pos.col >= 0 && pos.col < gameBoard.cols;
-                        const isOccupied = Number.isInteger(gameBoard.board[index]);
-                        if (isOccupied || !withinBounds) {
-                            break;
-                        } else {
-                            farthestPos = pos;
-                            testPos = pos;
-                        }
-                    }
-                    return farthestPos;
-                };
-
-                const coord = gameBoard.boardIndexToCoord(boardIndex);
-
-                const farthestCoords = Coord.allDirections
-                    .map(direction => getFarthestCoord(coord, direction))
-                    .filter(farCoord => farCoord !== undefined);
-
-                const farthestIndices = farthestCoords.map(c => gameBoard.coordToBoardIndex(c));
-                return farthestIndices;
-            };
-
-            const previousSelectedPieceId = player.selectedPieceId;
-            if (previousSelectedPieceId !== undefined) {
-                const previousPiece = document.getElementById(previousSelectedPieceId.toString());
-                previousPiece.classList.remove(constants.selectedPieceClassName);
-                dom.cells().forEach(cell => {
-                    cell.classList.remove(constants.possibleMoveClassName);
-                });
-            }
-
-            // read event data
-            const piece = event.target as Element;
-            const pieceId = parseInt(piece.id);
-
-            // set as selected
-            player.selectedPieceId = pieceId;
-            piece.classList.add(constants.selectedPieceClassName);
-
-            // hook up allowed moves for:
-            // 1. blink
-            // 2. complete the move
-            const pieceIndex = gameBoard.board.indexOf(pieceId);
-            const allowedMoveIndices = getAllowedMoveIndicesForIndex(pieceIndex);
-            allowedMoveIndices.forEach(i => {
-                const cell = dom.cells()[i];
-                listenerManager.addListener(cell, 'click', async () => await makeMove(i));
-                cell.classList.add(constants.possibleMoveClassName);
-            });
-        };
-
-        const makeMove = async (destIndex) => {
-            listenerManager.removeListeners();
-
-            const sourceIndex = gameBoard.board.indexOf(player.selectedPieceId);
-            player.moveHistory.push(new Move(sourceIndex, destIndex));
-
-            await movePiece(sourceIndex, destIndex);
-
-            checkForWin();
-            nextMove();
-        }
-
-        // mark active pieces
-        const playerPieces = dom.playerPieces(player.isRedTurn);
-        playerPieces.forEach(pp => {
-            pp.classList.add(constants.activePlayerPieceClassName);
-            listenerManager.addListener(pp, 'click', selectPiece);
-        });
-    };
-
-    // reset text
-    dom.redTurnText().forEach(t => t.classList.remove(constants.selectedPlayerTextClassName));
-    dom.blueTurnText().forEach(t => t.classList.remove(constants.selectedPlayerTextClassName));
-
-    // changePlayer
-    // 1. flip flag
-    // 2. set text
-    // 3. set click callbacks
-    if (player.isRedTurn) {
-        player.isRedTurn = false;
-        dom.blueTurnText().forEach(t => t.classList.add(constants.selectedPlayerTextClassName));
-    }
-    else {
-        player.isRedTurn = true;
-        dom.redTurnText().forEach(t => t.classList.add(constants.selectedPlayerTextClassName));
-    }
-
-    if (player.moveHistory.length === 0) {
-        dom.undoButton.disabled = true;
-        dom.resetButton.disabled = true;
-    } else {
-        dom.undoButton.disabled = false;
-        dom.resetButton.disabled = false;
-    }
-
-    player.selectedPieceId = undefined;
-    resetBoard();
-    setActivePlayerPieces();
-};
-
-const checkForWin = () => {
-    const elementsToCoords = (elements: Element[]): Coord[] => {
-        const elementsToBoardIndexes = (elements: Element[]): number[] => {
-            const indices = elements.map(pp => {
-                const pieceId = Number.parseInt(pp.id);
-                const pieceIndex = gameBoard.board.indexOf(pieceId);
-                return pieceIndex;
-            });
-            return indices;
-        };
-
-        const indices = elementsToBoardIndexes(elements);
-        const coords = indices.map(pieceIndex => gameBoard.boardIndexToCoord(pieceIndex));
-        return coords;
-    };
-    const doCoordsFormSquare = (coords: Coord[]): boolean => {
-        const allRows = coords.map(c => c.row);
-        const allCols = coords.map(c => c.col);
+export class Game implements IGame {
+  private isGameOver: boolean;
+  private readonly gameBoard: GameBoard;
+  private readonly _moveHistory: Move[];
+  public readonly player1Id: string;
+  public readonly player2Id: string;
+  public readonly player1Name: string;
+  public readonly player2Name: string;
+
+  public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string) {
+    this.gameBoard = new GameBoard(player1Id, player2Id);
+    this.player1Id = player1Id;
+    this.player2Id = player2Id;
+    this.player1Name = player1Name;
+    this.player2Name = player2Name;
+    this.isGameOver = false;
+    this._moveHistory = [];
+    this.currentPlayerId = player1Id;
+  }
+  getPiece(pos: Coord): string {
+    const value = this.gameBoard.get(pos);
+    if (value === null) return null;
+    const b = value.valueOf();
+    const id = b ? this.player1Id : this.player2Id;
+    return id;
+  }
+  public currentPlayerId: string;
+  public get size(): Coord {
+    return this.gameBoard.size;
+  }
+  public get moveHistory(): Move[] {
+    return this._moveHistory;
+  }
+  public getPlayerPositions(playerId: string): Coord[] {
+    return this.gameBoard.getCoords(playerId);
+  }
+  public coordToBoardIndex(coord: Coord): number {
+    return this.gameBoard.coordToBoardIndex(coord);
+  }
+  public async makeMove(source: Coord, dest: Coord): Promise<MoveResult> {
+    const checkForWin = (isP1Turn: boolean): MoveResult => {
+      const doCoordsFormSquare = (coords: Coord[]): boolean => {
+        const allRows = coords.map((c) => c.row);
+        const allCols = coords.map((c) => c.col);
         const min = new Coord(Math.min(...allRows), Math.min(...allCols));
         const max = new Coord(Math.max(...allRows), Math.max(...allCols));
         const diff = max.sub(min);
-        const isSquare = (diff.row === 1 && diff.col === 1);
+        const isSquare = diff.row === 1 && diff.col === 1;
         return isSquare;
-    };
-
-    const playerCoords = elementsToCoords(dom.playerPieces(player.isRedTurn))
-    const otherCoords = elementsToCoords(dom.playerPieces(!player.isRedTurn));
-    const squareCoords: Coord[] = [
-        new Coord(0, 0),
-        new Coord(3, 0),
-        new Coord(3, 3),
-        new Coord(0, 3),
-    ];
-
-    // winning conditions:
-    // 1. all in one row
-    // 2. all in one column
-    // 3. all in corners
-    // 4. all in square
-    // losing conditions:
-    // 1. on three sides of an opponent
-
-    const firstPiece = playerCoords[0];
-    const allInRow = playerCoords.every(pc => pc.row === firstPiece.row)
-    const allInCol = playerCoords.every(pc => pc.col === firstPiece.col)
-    const allInCorners = playerCoords.every(pc => squareCoords.findIndex(sc => pc.equals(sc)) >= 0);
-    const allInSquare = doCoordsFormSquare(playerCoords);
-
-    const winnerName = player.isRedTurn ? 'Red' : 'Blue';
-    let winningMessage = undefined;
-    if (allInRow) {
+      };
+      const { gameBoard, player1Id, player2Id, player1Name, player2Name } = this;
+      const playerId = isP1Turn ? player1Id : player2Id;
+
+      const playerCoords = gameBoard.getCoords(playerId);
+      const squareCoords: Coord[] = [new Coord(0, 0), new Coord(3, 0), new Coord(3, 3), new Coord(0, 3)];
+
+      // winning conditions:
+      // 1. all in one row
+      // 2. all in one column
+      // 3. all in corners
+      // 4. all in square
+      // losing conditions:
+      // 1. on three sides of an opponent
+
+      const firstPiece = playerCoords[0];
+      const allInRow = playerCoords.every((pc) => pc.row === firstPiece.row);
+      const allInCol = playerCoords.every((pc) => pc.col === firstPiece.col);
+      const allInCorners = playerCoords.every((pc) => squareCoords.findIndex((sc) => pc.equals(sc)) >= 0);
+      const allInSquare = doCoordsFormSquare(playerCoords);
+
+      const winnerName = isP1Turn ? player1Name : player2Name;
+      let winningMessage = undefined;
+      if (allInRow) {
         winningMessage = `${winnerName} wins by forming a row`;
-    }
-    if (allInCol) {
+      }
+      if (allInCol) {
         winningMessage = `${winnerName} wins by forming a column`;
-    }
-    if (allInCorners) {
+      }
+      if (allInCorners) {
         winningMessage = `${winnerName} wins by putting pieces in corners`;
-    }
-    if (allInSquare) {
+      }
+      if (allInSquare) {
         winningMessage = `${winnerName} wins by forming a square`;
-    }
-
-    if (winningMessage !== undefined) {
-        alert(winningMessage);
-        document.location.reload();
-    }
-};
+      }
+      if (winningMessage) {
+        return {
+          isTerminal: true,
+          message: winningMessage,
+        };
+      } else {
+        return {
+          isTerminal: false,
+        };
+      }
+    };
 
-const init = () => {
-    // create board and pieces
-    let isWhite = true;
-    const table = document.getElementById('table');
-    for (let r = 0; r < gameBoard.rows; r++) {
-        const tr = document.createElement('tr');
-        table.appendChild(tr);
-        for (let c = 0; c < gameBoard.cols; c++) {
-            const td = document.createElement('td');
-            tr.appendChild(td);
-            td.className = isWhite ? 'white' : 'black';
-            isWhite = !isWhite;
-        }
-        isWhite = !isWhite;
-    }
-    gameBoard.board.forEach((value, index) => {
-        if (value !== null) {
-            const isRed = value < 4;
-            const defaultPiece = isRed ? dom.defaultRedPiece : dom.defaultBluePiece;
-            const newPiece = defaultPiece.cloneNode(true) as HTMLParagraphElement;
-            newPiece.id = value.toString();
-            dom.cells()[index].appendChild(newPiece);
-        }
-    });
+    const { gameBoard, player1Id, player2Id } = this;
+    const isP1Turn = this.currentPlayerId === player1Id;
+    const playerId = isP1Turn ? player1Id : player2Id;
 
-    // hook up buttons
-    let blockActions = false;
-    dom.resetButton.addEventListener('click', () => {
-        if (blockActions) {
-            return;
-        }
-        if (player.moveHistory.length === 0) {
-            return;
-        }
+    this._moveHistory.push(new Move(playerId, source, dest));
+    gameBoard.move(source, dest);
 
-        blockActions = true;
-        if (confirm('Reset?')) {
-            window.location.reload();
-        }
-        blockActions = false;
-    });
-    dom.undoButton.addEventListener('click', async () => {
-        if (blockActions) {
-            return;
+    const result = checkForWin(isP1Turn);
+    if (result.isTerminal) {
+      this.isGameOver = true;
+    } else {
+      this.currentPlayerId = isP1Turn ? this.player2Id : this.player1Id;
+    }
+    return result;
+  }
+  public getValidMoves(start: Coord): Coord[] {
+    const getFarthestCoord = (startCoord: Coord, direction: Coord): Coord => {
+      const { gameBoard } = this;
+      let farthestPos = undefined;
+      let testPos = startCoord;
+      while (true) {
+        const pos = new Coord(testPos.row + direction.row, testPos.col + direction.col);
+
+        const withinBounds = gameBoard.size.contains(pos);
+        if (!withinBounds) {
+          break;
         }
-        if (player.moveHistory.length === 0) {
-            return;
+        const isOccupied = gameBoard.get(pos) !== null;
+        if (isOccupied) {
+          break;
         }
 
-        blockActions = true;
-
-        player.selectedPieceId = undefined;
-        resetBoard();
+        farthestPos = pos;
+        testPos = pos;
+      }
+      return farthestPos;
+    };
 
-        const { destIndex, sourceIndex } = player.moveHistory.pop();
-        await movePiece(destIndex, sourceIndex);
-        nextMove();
-        blockActions = false;
-    });
-};
+    const farthestCoords = Coord.allDirections
+      .map((direction) => getFarthestCoord(start, direction))
+      .filter((farCoord) => farCoord !== undefined);
 
-// start!
-init();
-nextMove();
\ No newline at end of file
+    return farthestCoords;
+  }
+  public undoMove(): Move {
+    if (!this.canUndoMove) {
+      throw "Newp! Can't undo!";
+    }
+    const { gameBoard } = this;
+    const move = this._moveHistory.pop();
+    const { source, dest, playerId } = move;
+    this.currentPlayerId = playerId;
+    gameBoard.move(dest, source);
+    return move;
+  }
+  public get canUndoMove(): boolean {
+    if (this._moveHistory.length === 0 || this.isGameOver) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/src/listeners.ts b/src/listeners.ts
new file mode 100644
index 0000000..2ffe56e
--- /dev/null
+++ b/src/listeners.ts
@@ -0,0 +1,24 @@
+export interface Listener {
+    element: Element;
+    type: string;
+    listener: (event: Event) => void;
+}
+
+export class ListenerManager {
+    private listeners: Listener[] = [];
+
+    public addListener(element: Element, type: string, listener: (event: Event) => void): void {
+        const { listeners } = this;
+        element.addEventListener(type, listener);
+        listeners.push({ element, type, listener });
+    }
+
+    public removeListeners() {
+        const { listeners } = this;
+        listeners.forEach(value => {
+            const { element, type, listener } = value;
+            element.removeEventListener(type, listener);
+        });
+        listeners.length = 0;
+    }
+}
diff --git a/src/local-game.ts b/src/local-game.ts
new file mode 100644
index 0000000..ec5a19b
--- /dev/null
+++ b/src/local-game.ts
@@ -0,0 +1,264 @@
+import { Game, IGame } from './game';
+import { Coord, MoveResult } from './types';
+import { ListenerManager } from './listeners';
+import { showModal } from './modal';
+
+const classNames = {
+  selectedPiece: 'selected-piece',
+  selectedPlayerText: 'selected-player-text',
+  activePlayerPiece: 'active-player-piece',
+  possibleMove: 'possible-move',
+};
+
+export class LocalGame extends Game {
+  selectedPiece: Element | null = null;
+  listenerManager: ListenerManager = new ListenerManager();
+
+  // DOM references
+  readonly dom = {
+    defaultP1Piece: document.getElementById('default-p1-piece'),
+    defaultP2Piece: document.getElementById('default-p2-piece'),
+    resetButton: document.getElementById('reset-button') as HTMLInputElement,
+    undoButton: document.getElementById('undo-button') as HTMLInputElement,
+    cells: () => Array.from(document.querySelectorAll('td')),
+    allPieces: () => Array.from(document.querySelectorAll('.piece')),
+    p1TurnText: () => Array.from(document.querySelectorAll('.p1-turn-text'))[0],
+    p2TurnText: () => Array.from(document.querySelectorAll('.p2-turn-text'))[0],
+  };
+
+  private getPiecesAndCoords(playerId: string) {
+    const allCells = this.dom.cells();
+    const playerPositions = this.getPlayerPositions(playerId);
+    const piecesAndPositions = playerPositions.map((coord) => {
+      const index = this.coordToBoardIndex(coord);
+      const cell = allCells[index];
+      const piece = cell.children[0];
+      return { coord, piece };
+    });
+    return piecesAndPositions;
+  }
+
+  /**
+   * Animates the motion of piece moving from source to dest
+   * @param source Starting Coord
+   * @param dest Ending Coord
+   * @param playerId Player id
+   */
+  private async movePiece(source: Coord, dest: Coord) {
+    const { dom } = this;
+    const sourceIndex = this.coordToBoardIndex(source);
+    const destIndex = this.coordToBoardIndex(dest);
+
+    // find current piece
+    const piece = dom.cells()[sourceIndex].children[0];
+    // unselect piece
+    piece.classList.remove(classNames.selectedPiece);
+
+    // create clone for new position
+    const destPiece = piece.cloneNode(true);
+    // add to destination cell
+    dom.cells()[destIndex].appendChild(destPiece);
+
+    // start animations
+    const animDurationMs = 500;
+    const destAnimation = dom.cells()[destIndex].animate([{ opacity: 0 }, { opacity: 1 }], { duration: animDurationMs, iterations: 1 });
+    const sourceAnimation = piece.animate(
+      [
+        {
+          opacity: 1,
+          width: '45px',
+          height: '45px',
+        },
+        {
+          opacity: 0,
+          width: '5px',
+          height: '5px',
+        },
+      ],
+      { duration: animDurationMs, iterations: 1 }
+    );
+
+    // await animations
+    await Promise.all([sourceAnimation.finished, destAnimation.finished]);
+
+    // remove old piece
+    piece.parentNode.removeChild(piece);
+  }
+
+  public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string) {
+    super(player1Id, player1Name, player2Id, player2Name);
+    this.init();
+  }
+
+  public override async makeMove(source: Coord, dest: Coord): Promise<MoveResult> {
+    this.listenerManager.removeListeners();
+    await this.movePiece(source, dest);
+    const result = await super.makeMove(source, dest);
+    this.handleMoveResult(result);
+    return result;
+  }
+
+  public async handleMoveResult(result: MoveResult) {
+    const { isTerminal, message } = result;
+    if (isTerminal) {
+      await showModal(message);
+      document.location.reload();
+    } else {
+      this.resetUi();
+      this.setActiveUi();
+    }
+  }
+
+  public resetUi() {
+    const { dom, currentPlayerId, player1Id, canUndoMove, moveHistory, listenerManager, selectedPiece } = this;
+
+    // reset text
+    dom.p1TurnText().classList.remove(classNames.selectedPlayerText);
+    dom.p2TurnText().classList.remove(classNames.selectedPlayerText);
+
+    const isP1Turn = currentPlayerId === player1Id;
+    if (isP1Turn) {
+      dom.p1TurnText().classList.add(classNames.selectedPlayerText);
+    } else {
+      dom.p2TurnText().classList.add(classNames.selectedPlayerText);
+    }
+
+    if (dom.undoButton || dom.resetButton) {
+      dom.undoButton.disabled = canUndoMove;
+      if (moveHistory.length === 0) {
+        dom.undoButton.disabled = true;
+        dom.resetButton.disabled = true;
+      } else {
+        dom.undoButton.disabled = false;
+        dom.resetButton.disabled = false;
+      }
+    }
+
+    this.selectedPiece = null;
+
+    // reset Cells
+    dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove));
+    // reset pieces
+    dom.allPieces().forEach((piece) => {
+      piece.classList.remove(classNames.activePlayerPiece);
+      piece.classList.remove(classNames.selectedPiece);
+    });
+    // remove all listeners
+    listenerManager.removeListeners();
+  }
+  public setActiveUi() {
+    const { dom, currentPlayerId, listenerManager } = this;
+
+    const clearPossibleMovesBlink = () =>
+      dom.cells().forEach((cell) => cell.classList.remove(classNames.possibleMove));
+    const activePieceListener = (piece: Element, source: Coord) => {
+      // clear current selection
+      if (this.selectedPiece !== null) {
+        this.selectedPiece.classList.remove(classNames.selectedPiece);
+        clearPossibleMovesBlink();
+        listenerManager.removeListeners();
+        hookupActivePieces();
+      }
+
+      // set as selected
+      this.selectedPiece = piece;
+      piece.classList.add(classNames.selectedPiece);
+
+      // hook up allowed moves for:
+      // 1. blink
+      // 2. complete the move
+      const allowedMoves = this.getValidMoves(source);
+      allowedMoves.forEach((dest: Coord) => {
+        const cell = dom.cells()[this.coordToBoardIndex(dest)];
+        cell.classList.add(classNames.possibleMove);
+        listenerManager.addListener(cell, 'click', async () => {
+          clearPossibleMovesBlink();
+          await this.makeMove(source, dest);
+        });
+      });
+    };
+
+    const hookupActivePieces = () => {
+      // mark active pieces
+      const piecesAndPositions = this.getPiecesAndCoords(currentPlayerId);
+      piecesAndPositions.forEach(({ coord: source, piece }) => {
+        piece.classList.add(classNames.activePlayerPiece);
+        listenerManager.addListener(piece, 'click', () => activePieceListener(piece, source));
+      });
+    }
+
+    hookupActivePieces();
+  }
+
+  private init() {
+    const { dom, player1Id, player1Name, player2Id, player2Name, size } = this;
+
+    // create board and pieces
+    let isWhite = true;
+    const table = document.getElementById('table');
+    for (let r = 0; r < size.row; r++) {
+      const tr = document.createElement('tr');
+      table.appendChild(tr);
+      for (let c = 0; c < size.col; c++) {
+        const td = document.createElement('td');
+        tr.appendChild(td);
+        td.classList.add('tile');
+        td.classList.add(isWhite ? 'white' : 'black');
+        isWhite = !isWhite;
+      }
+      isWhite = !isWhite;
+    }
+    const createPiece = (isP1Turn: boolean, pieceId: string, coord: Coord) => {
+      const defaultPiece = isP1Turn ? dom.defaultP1Piece : dom.defaultP2Piece;
+      const newPiece = defaultPiece.cloneNode(true) as HTMLParagraphElement;
+      newPiece.id = pieceId;
+      const index = this.coordToBoardIndex(coord);
+      dom.cells()[index].appendChild(newPiece);
+    };
+    this.getPlayerPositions(player1Id).forEach((coord) => createPiece(true, player1Id, coord));
+    this.getPlayerPositions(player2Id).forEach((coord) => createPiece(false, player2Id, coord));
+
+    dom.p1TurnText().textContent = `${player1Name}`
+    dom.p2TurnText().textContent = `${player2Name}`
+
+    // hook up buttons
+    let blockActions = false;
+    if (dom.resetButton) {
+      dom.resetButton.addEventListener('click', () => {
+        if (blockActions) {
+          return;
+        }
+
+        blockActions = true;
+        if (confirm('Reset?')) {
+          window.location.reload();
+        }
+        blockActions = false;
+      });
+    }
+
+    if (dom.undoButton) {
+      dom.undoButton.addEventListener('click', async () => {
+        if (blockActions) {
+          return;
+        }
+
+        blockActions = true;
+
+        const move = this.undoMove();
+        await this.movePiece(move.dest, move.source);
+        this.resetUi();
+        this.setActiveUi();
+
+        blockActions = false;
+      });
+    }
+  }
+}
+
+// start!
+export const playGame = () => {
+  const lg = new LocalGame('red-player', 'Red', 'blue-player', 'Blue');
+  lg.resetUi();
+  lg.setActiveUi();
+};
diff --git a/src/local.ts b/src/local.ts
new file mode 100644
index 0000000..22236bd
--- /dev/null
+++ b/src/local.ts
@@ -0,0 +1,3 @@
+import { playGame } from './local-game';
+
+playGame();
\ No newline at end of file
diff --git a/src/modal.ts b/src/modal.ts
new file mode 100644
index 0000000..92770d6
--- /dev/null
+++ b/src/modal.ts
@@ -0,0 +1,59 @@
+/*
+    <div class="modal">
+        <div class="modal-content">
+            <span class="close">Close</span>
+            <p></p>
+        </div>
+    </div>
+*/
+export const showModal = (text): Promise<void> => {
+    const modal = document.createElement('div');
+    modal.classList.add('modal');
+
+    const modalContentDiv = document.createElement('div');
+    modalContentDiv.classList.add('modal-content');
+    modal.appendChild(modalContentDiv);
+
+    const closeSpan = document.createElement('span');
+    closeSpan.classList.add('close');
+    closeSpan.innerText = 'x';
+    modalContentDiv.appendChild(closeSpan);
+
+    const modalContentPara = document.createElement('p');
+    modalContentPara.id = 'modal-content';
+    modalContentPara.innerText = text;
+    modalContentDiv.appendChild(modalContentPara);
+
+    document.body.appendChild(modal);
+
+    // Get the <span> element that closes the modal
+    var span = document.getElementsByClassName("close")[0] as HTMLSpanElement;
+
+    var content = document.getElementById('modal-content') as HTMLParagraphElement;
+    content.innerText = text;
+
+    const promise = new Promise<void>((resolve, reject) => {
+        let isModalOpen = true;
+        const closeModal = () => {
+            modal.style.display = "none";
+            isModalOpen = false;
+            modal.parentNode.removeChild(modal);
+            resolve();
+        }
+
+        // When the user clicks on <span> (x), close the modal
+        span.onclick = () => closeModal();
+
+        // When the user clicks anywhere outside of the modal, close it
+        window.onclick = (event: MouseEvent) => {
+            if (event.target == modal) {
+                closeModal();
+            }
+        };
+    });
+
+    // show modal
+    modal.style.display = "block";
+
+    return promise;
+}
\ No newline at end of file
diff --git a/src/online.ts b/src/online.ts
new file mode 100644
index 0000000..b314e49
--- /dev/null
+++ b/src/online.ts
@@ -0,0 +1,3 @@
+import { playGame } from './remote-game';
+
+playGame();
\ No newline at end of file
diff --git a/src/remote-game.ts b/src/remote-game.ts
new file mode 100644
index 0000000..8d642b3
--- /dev/null
+++ b/src/remote-game.ts
@@ -0,0 +1,212 @@
+import Peer, { PeerJSOption } from 'peerjs';
+import { LocalGame } from './local-game';
+import { showModal } from './modal';
+import { useSessionStorage } from './storage';
+import { Coord, Move, MoveResult } from './types';
+import { delay, generateHash, showSnackbar } from './utils';
+
+interface LocalData {
+    id: string;
+    peerId: string;
+}
+interface Pos {
+    x: number;
+    y: number;
+}
+interface PeerData {
+    mousePos?: Pos;
+    move?: Move;
+}
+const peerOptions: PeerJSOption = { debug: 0 };
+const { getter: getData, setter: setData } = useSessionStorage<LocalData>('dao-game', { id: undefined, peerId: undefined });
+const otherMouseElement = document.getElementById('other-mouse');
+const shareContainer = document.getElementById('share-container');
+const shareLinkButton = document.getElementById('share-link');
+const openInNewTab = (url: string) => {
+    const openInSystemDefault = false;
+    if (openInSystemDefault) {
+        window.open(`microsoft-edge:${url}`, '_blank');
+    } else {
+        //   opening in the same browser causes a copy of the session storage,
+        //   so we clear the session storage, open the url, and restore the session storage
+        const data = getData();
+        sessionStorage.clear();
+        try {
+            window.open(url, '_blank');
+        } finally {
+            setData(data);
+        }
+    }
+};
+const minString = (a: string, b: string): string => (a < b ? a : b);
+const maxString = (a: string, b: string): string => (a > b ? a : b);
+const getFirstPlayerId = (playerId1: string, playerId2: string) => {
+    const hash1 = generateHash(playerId1);
+    const hash2 = generateHash(playerId2);
+    const prod = hash1 * hash2;
+    const isEven = prod % 2 == 0;
+    const id = isEven ? minString(playerId1, playerId2) : maxString(playerId1, playerId2);
+    return id;
+};
+const dropFileExtension = (path: string) => {
+    const lastSlashIndex = path.lastIndexOf('/');
+    const dirName = path.substring(0, lastSlashIndex);
+    return dirName;
+}
+
+export class PeerGame extends LocalGame {
+    public conn: Peer.DataConnection;
+    public selfId: string;
+    public constructor(player1Id: string, player1Name: string, player2Id: string, player2Name: string, selfId: string, conn: Peer.DataConnection) {
+        super(player1Id, player1Name, player2Id, player2Name);
+        this.selfId = selfId;
+        this.conn = conn;
+    }
+    public override setActiveUi(): void {
+        const { currentPlayerId, selfId } = this;
+
+        const isLocalTurn = currentPlayerId === selfId;
+        if (isLocalTurn) {
+            super.setActiveUi();
+        }
+    }
+    public override makeMove(source: Coord, dest: Coord): Promise<MoveResult> {
+        const result = super.makeMove(source, dest);
+        const peerData: PeerData = { move: { source, dest, playerId: this.currentPlayerId } };
+        this.conn.send(JSON.stringify(peerData));
+        return result;
+    }
+    public override async handleMoveResult(result: MoveResult): Promise<void> {
+        const { isTerminal, message } = result;
+        if (result.isTerminal) {
+            await showModal(message);
+            sessionStorage.clear();
+            const { protocol, host, pathname } = location;
+            const dir = dropFileExtension(pathname);
+            const newUrl = `${protocol}//${host}${dir}`
+            document.location = newUrl;
+        } else {
+            this.resetUi();
+            this.setActiveUi();
+        }
+    }
+
+    public static async createPeerGame(): Promise<PeerGame> {
+        let { id, peerId } = getData();
+        const main = document.getElementsByTagName('main')[0];
+        let game: PeerGame = undefined;
+
+        const handleConn = (conn: Peer.DataConnection) => {
+            if (game !== undefined) {
+                conn.close();
+                return;
+            }
+
+            shareContainer.classList.add('invisible');
+
+            id = peer.id;
+            peerId = conn.peer;
+            setData({ id, peerId });
+
+            const isLocalP1 = getFirstPlayerId(id, peerId) === id;
+            game = isLocalP1 ?
+                new PeerGame(id, "Local", peerId, "Remote", id, conn) :
+                new PeerGame(peerId, "Remote", id, "Local", id, conn);
+            game.resetUi();
+            game.setActiveUi();
+            main.classList.remove('invisible');
+
+            const table = document.getElementById('table');
+            table.addEventListener('mousemove', (ev: MouseEvent) => {
+                const xTable = ev.clientX - table.offsetLeft;
+                const yTable = ev.clientY - table.offsetTop;
+                const xRatio = xTable / table.clientWidth;
+                const yRatio = yTable / table.clientHeight;
+                const peerData: PeerData = { mousePos: { x: xRatio, y: yRatio } };
+                conn.send(JSON.stringify(peerData));
+            });
+            table.addEventListener('mouseleave', () => {
+                const peerData: PeerData = { mousePos: { x: -1, y: -1 } };
+                conn.send(JSON.stringify(peerData));
+            });
+
+            let lastTimerId = undefined;
+            conn.on('data', (data: string) => {
+                const peerData = JSON.parse(data) as PeerData;
+                const { mousePos, move } = peerData;
+
+                if (mousePos) {
+                    const isValidPos = mousePos.x !== -1 && mousePos.y !== -1;
+                    if (isValidPos) {
+                        const xRatio = mousePos.x;
+                        const yRatio = mousePos.y;
+                        const xTable = xRatio * table.clientWidth;
+                        const yTable = yRatio * table.clientHeight;
+                        const x = table.offsetLeft + xTable;
+                        const y = table.offsetTop + yTable;
+                        const style = `left:${x - otherMouseElement.clientWidth / 2}px;top:${y}px;display:block;`;
+                        otherMouseElement.setAttribute('style', style);
+                        if (lastTimerId !== undefined) {
+                            clearTimeout(lastTimerId);
+                        }
+                        lastTimerId = setTimeout(() => {
+                            otherMouseElement.setAttribute('style', '');
+                            lastTimerId = undefined;
+                        }, 3000);
+                    } else if (lastTimerId !== undefined) {
+                        otherMouseElement.setAttribute('style', '');
+                    }
+                }
+
+                const isWaitingForRemoteMove = peerId === game.currentPlayerId;
+                if (isWaitingForRemoteMove && move) {
+                    const isMoveLegal = game.getPiece(move.source) === game.currentPlayerId && game.getPiece(move.dest) === null;
+                    if (isMoveLegal) {
+                        game.makeMove(move.source, move.dest);
+                    } else {
+                        console.log({ isMoveLegal, move });
+                    }
+                }
+            });
+        };
+
+        const { host, protocol, pathname, search } = location;
+        const queryParams = new URLSearchParams(search);
+
+        const peer = id === undefined ? new Peer(peerOptions) : new Peer(id, peerOptions);
+        window.onunload = () => peer.destroy();
+        peer.on('connection', (conn) => handleConn(conn));
+        peer.on('error', (err) => console.error({ err }));
+        peer.on('open', (trueId) => {
+            id = trueId;
+            peerId = queryParams.get('peerId') ?? peerId;
+            setData({ id, peerId });
+
+            if (peerId) {
+                console.log(`${id} connecting to peer "${peerId}"`);
+                const conn = peer.connect(peerId);
+                handleConn(conn);
+            } else {
+                queryParams.set('peerId', id);
+                const newSearch = queryParams.toString();
+                const newUrl = `${protocol}//${host}${pathname}?${newSearch}`;
+                shareLinkButton.onclick = () => {
+                    navigator.clipboard.writeText(newUrl);
+                    showSnackbar('Url copied to clipboard');
+                };
+                shareContainer.classList.remove('invisible');
+                // openInNewTab(newUrl);
+            }
+        });
+
+        while (game === undefined) {
+            console.log('Waiting to connect to peer');
+            await delay(500);
+        }
+        return game;
+    }
+}
+
+export const playGame = async () => {
+    const game = await PeerGame.createPeerGame();
+};
diff --git a/src/storage.ts b/src/storage.ts
new file mode 100644
index 0000000..d05cd5c
--- /dev/null
+++ b/src/storage.ts
@@ -0,0 +1,27 @@
+export interface StorageInterface<T> {
+  getter: () => T;
+  setter: (data: T) => void;
+}
+export const useSessionStorage = <T>(
+  key: string,
+  defaultData?: T
+): StorageInterface<T> => {
+  return {
+    getter: () => {
+      let item = sessionStorage.getItem(key);
+      if (item === null && defaultData !== undefined) {
+        item = JSON.stringify(defaultData);
+        sessionStorage.setItem(key, item);
+      }
+      const data = JSON.parse(item) as T;
+      return data;
+    },
+    setter: (data: T) => {
+      const text = JSON.stringify(data as T);
+      console.log(`Saving data to local storage`);
+      console.log(`Key=${key}`);
+      console.log(`Text=${text}`);
+      sessionStorage.setItem(key, text);
+    },
+  };
+};
diff --git a/src/types.ts b/src/types.ts
index 9ba103b..9dfa8e0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,103 +1,64 @@
 export class Coord {
-    public row: number;
-    public col: number;
+  public row: number;
+  public col: number;
 
-    constructor(row: number, col: number) {
-        this.row = row;
-        this.col = col;
-    }
+  constructor(row: number, col: number) {
+    this.row = row;
+    this.col = col;
+  }
 
-    public add(coord: Coord): Coord {
-        return new Coord(this.row + coord.row, this.col + coord.col);
-    }
+  public add(coord: Coord): Coord {
+    return new Coord(this.row + coord.row, this.col + coord.col);
+  }
 
-    sub(coord: Coord): Coord {
-        return new Coord(this.row - coord.row, this.col - coord.col);
-    }
+  sub(coord: Coord): Coord {
+    return new Coord(this.row - coord.row, this.col - coord.col);
+  }
 
-    equals(otherCoord: Coord): boolean {
-        const areEqual = (this.col === otherCoord.col && this.row === otherCoord.row);
-        return areEqual;
-    }
+  /**
+   * Finds if otherCoord is contained within a rectangle that sits between origin and this Coord
+   * @param otherCoord
+   */
+  contains(otherCoord: Coord): boolean {
+    const xRatio = otherCoord.col / this.col;
+    const yRatio = otherCoord.row / this.row;
+    const xyInsideSquare = xRatio >= 0 && xRatio < 1 && yRatio >= 0 && yRatio < 1;
+    return xyInsideSquare;
+  }
 
-    // clock face directions
-    static get allDirections(): Coord[] {
-        const allDirections = [
-            new Coord(1, 0), // up
-            new Coord(1, 1), // up-right
-            new Coord(0, 1), // right
-            new Coord(-1, 1), // right-down
-            new Coord(-1, 0), // down
-            new Coord(-1, -1), // down-left
-            new Coord(0, -1), // left
-            new Coord(1, -1), // left-up
-        ]
-        return allDirections;
-    }
+  equals(otherCoord: Coord): boolean {
+    const areEqual = this.col === otherCoord.col && this.row === otherCoord.row;
+    return areEqual;
+  }
 
+  // clock face directions
+  static get allDirections(): Coord[] {
+    const allDirections = [
+      new Coord(1, 0), // up
+      new Coord(1, 1), // up-right
+      new Coord(0, 1), // right
+      new Coord(-1, 1), // right-down
+      new Coord(-1, 0), // down
+      new Coord(-1, -1), // down-left
+      new Coord(0, -1), // left
+      new Coord(1, -1), // left-up
+    ];
+    return allDirections;
+  }
 }
 
 export class Move {
-    public sourceIndex: number;
-    public destIndex: number;
-    constructor(sourceIndex: number, destIndex: number) {
-        this.sourceIndex = sourceIndex;
-        this.destIndex = destIndex;
-    }
+  public source: Coord;
+  public dest: Coord;
+  public playerId: string;
+  constructor(playerId: string, source: Coord, dest: Coord) {
+    this.source = source;
+    this.dest = dest;
+    this.playerId = playerId;
+  }
 }
 
-interface Listener {
-    element: Element;
-    type: string;
-    listener: (event: Event) => void;
+export interface MoveResult {
+  isTerminal: boolean;
+  message?: string;
 }
-
-export class ListenerManager {
-    private listeners: Listener[] = [];
-
-    public addListener(element: Element, type: string, listener: (event: Event) => void): void {
-        const { listeners } = this;
-        element.addEventListener(type, listener);
-        listeners.push({ element, type, listener });
-    }
-
-    public removeListeners() {
-        const { listeners } = this;
-        listeners.forEach(value => {
-            const { element, type, listener } = value;
-            element.removeEventListener(type, listener);
-        });
-        listeners.length = 0;
-    }
-}
-
-export class GameBoard {
-    public board: number[];
-    public rows: number;
-    public cols: number;
-    // testBoard: [
-    //     4, null, null, null,
-    //     null, 0, 1, 5,
-    //     null, 2, 3, null,
-    //     6, null, null, 7
-    // ],
-    public constructor() {
-        this.board = [
-            0, null, null, 4,
-            null, 1, 5, null,
-            null, 6, 2, null,
-            7, null, null, 3
-        ];
-        this.rows = 4;
-        this.cols = 4;
-    }
-    public coordToBoardIndex({ row, col }: Coord): number {
-        const index = row * 4 + col;
-        return index;
-    }
-    public boardIndexToCoord(index: number): Coord {
-        const col = index % 4;
-        const row = Math.floor(index / 4);
-        return new Coord(row, col);
-    }
-}
\ No newline at end of file
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..b97d40e
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,32 @@
+export function generateHash(text: string) {
+  var hash = 0;
+  if (text.length == 0) {
+    return hash;
+  }
+  for (let i = 0; i < text.length; i++) {
+    var charCode = text.charCodeAt(i);
+    hash = (hash << 7) - hash + charCode;
+    hash = hash & hash;
+  }
+  return hash;
+}
+
+export async function delay(delayInMs: number) {
+  return new Promise((resolve) => {
+    setTimeout(() => {
+      resolve(2);
+    }, delayInMs);
+  });
+}
+
+export function showSnackbar(text) {
+  // Get the snackbar DIV
+  var x = document.getElementById("snackbar");
+
+  // Add the "show" class to DIV
+  x.className = "show";
+  x.innerText = text;
+
+  // After 3 seconds, remove the show class from DIV
+  setTimeout(function () { x.className = x.className.replace("show", ""); }, 3000);
+}
\ No newline at end of file
diff --git a/tools/esbuild-build-and-serve.ts b/tools/esbuild-build-and-serve.ts
index 3be8fb1..144ba95 100644
--- a/tools/esbuild-build-and-serve.ts
+++ b/tools/esbuild-build-and-serve.ts
@@ -1,44 +1,34 @@
-import childProcess from 'child_process';
 import esbuild from 'esbuild';
 import http from 'http';
+import { serveOptions, buildOptions, hostPort } from './esbuild-settings'
 
-const hostPort = 3000;
+esbuild.serve(serveOptions, buildOptions).then((result) => {
+  const { host, port } = result;
+  http
+    .createServer((req, res) => {
+      const options: http.RequestOptions = {
+        hostname: host,
+        port: port,
+        path: req.url,
+        method: req.method,
+        headers: req.headers,
+      };
+      const proxyReq = http.request(options, (proxyRes) => {
+        if (proxyRes.statusCode === 404) {
+          res.writeHead(404, { 'Content-Type': 'text/html' });
+          res.end('<h1>A custom 404 page</h1>');
+          return;
+        }
 
-const serveOptions: esbuild.ServeOptions = {
-    servedir: 'public'
-};
-const buildOptions: esbuild.BuildOptions = {
-    entryPoints: ['src/game.ts'],
-    bundle: true,
-    outfile: 'public/bundle.js',
-    sourcemap: true
-};
-esbuild.serve(serveOptions, buildOptions)
-    .then(result => {
-        const { host, port } = result;
-        http
-            .createServer((req, res) => {
-                const options: http.RequestOptions = {
-                    hostname: host,
-                    port: port,
-                    path: req.url,
-                    method: req.method,
-                    headers: req.headers
-                };
-                const proxyReq = http.request(options, proxyRes => {
-                    if (proxyRes.statusCode === 404) {
-                        res.writeHead(404, { 'Content-Type': 'text/html' });
-                        res.end('<h1>A custom 404 page</h1>');
-                        return;
-                    }
+        res.writeHead(proxyRes.statusCode!, proxyRes.headers);
+        proxyRes.pipe(res, { end: true });
+      });
 
-                    res.writeHead(proxyRes.statusCode!, proxyRes.headers);
-                    proxyRes.pipe(res, { end: true });
-                });
-
-                req.pipe(proxyReq, { end: true });
-            })
-            .listen(hostPort, () => {
-                childProcess.exec(`start http://localhost:${hostPort}/`);
-            });
-    });
\ No newline at end of file
+      req.pipe(proxyReq, { end: true });
+    })
+    .listen(hostPort, () => {
+      const url = `http://localhost:${hostPort}/`;
+      // childProcess.exec(`start ${url}`);
+      console.log(`Server serving at ${url}`);
+    });
+});
diff --git a/tools/esbuild-settings.ts b/tools/esbuild-settings.ts
new file mode 100644
index 0000000..d6c5afd
--- /dev/null
+++ b/tools/esbuild-settings.ts
@@ -0,0 +1,13 @@
+import esbuild from 'esbuild';
+
+export const hostPort = 3000;
+
+export const serveOptions: esbuild.ServeOptions = {
+    servedir: 'public',
+};
+export const buildOptions: esbuild.BuildOptions = {
+    entryPoints: ['src/local.ts', 'src/online.ts'],
+    bundle: true,
+    outdir: 'public/js',
+    sourcemap: true,
+};
diff --git a/tools/esbuild-watch.ts b/tools/esbuild-watch.ts
index 602f1c3..e37f236 100644
--- a/tools/esbuild-watch.ts
+++ b/tools/esbuild-watch.ts
@@ -1,8 +1,4 @@
 import esbuild from 'esbuild';
+import { buildOptions } from './esbuild-settings'
 
-esbuild.build({
-    entryPoints: ['src/game.ts'],
-    bundle: true,
-    outfile: 'public/bundle.js',
-    watch: true,
-}).catch(() => process.exit(1))
\ No newline at end of file
+esbuild.build(buildOptions).catch(() => process.exit(1))
\ No newline at end of file
diff --git a/tools/esbuild.ts b/tools/esbuild.ts
index 24ef6db..e9dce4e 100644
--- a/tools/esbuild.ts
+++ b/tools/esbuild.ts
@@ -1,7 +1,4 @@
 import esbuild from 'esbuild';
+import { serveOptions, buildOptions, hostPort } from './esbuild-settings'
 
-esbuild.build({
-    entryPoints: ['src/game.ts'],
-    bundle: true,
-    outfile: 'public/bundle.js',
-}).catch(() => process.exit(1))
\ No newline at end of file
+esbuild.build(buildOptions).catch(() => process.exit(1))
\ No newline at end of file